358 lines
14 KiB
Go
358 lines
14 KiB
Go
package projects
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
"senlinai-agent/backend/internal/config"
|
|
"senlinai-agent/backend/internal/httpx"
|
|
"senlinai-agent/backend/internal/logic/files"
|
|
"senlinai-agent/backend/internal/models"
|
|
)
|
|
|
|
func TestCreateProjectHandlerPersistsMetadata(t *testing.T) {
|
|
router, _, _ := newProjectsHandlerTestRouter(t)
|
|
body, err := json.Marshal(gin.H{
|
|
"name": "Explore",
|
|
"identifier": "EXP",
|
|
"icon": "compass",
|
|
"background": "#165DFF",
|
|
"description": "RSS exploration workspace",
|
|
})
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusCreated, rec.Code)
|
|
var project models.SenlinAgentProject
|
|
require.NoError(t, models.DBService.Where("owner_id = ? AND identifier = ?", 1, "EXP").First(&project).Error)
|
|
require.Equal(t, "Explore", project.Name)
|
|
require.Equal(t, "compass", project.Icon)
|
|
require.Equal(t, "#165DFF", project.Background)
|
|
require.Equal(t, "RSS exploration workspace", project.Description)
|
|
|
|
var payload map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
|
require.Equal(t, project.Identity, payload["id"])
|
|
require.ElementsMatch(t, []string{"id", "name", "identifier", "icon", "background", "description"}, mapKeys(payload))
|
|
}
|
|
|
|
func TestCreateProjectReturnsChineseInvalidRequestEnvelope(t *testing.T) {
|
|
router, _, _ := newProjectsHandlerTestRouter(t)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader([]byte(`{"name":" "}`)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
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_request", payload.Error.Code)
|
|
require.Equal(t, "请求参数无效", payload.Error.Message)
|
|
}
|
|
|
|
func TestCreateProjectReturnsConflictForDuplicateIdentifier(t *testing.T) {
|
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
body, err := json.Marshal(gin.H{"name": "Another", "identifier": project.Identifier})
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusConflict, rec.Code)
|
|
var payload httpx.ErrorEnvelope
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
|
require.Equal(t, "conflict", payload.Error.Code)
|
|
require.Equal(t, "项目标识已存在", payload.Error.Message)
|
|
}
|
|
|
|
func TestGetProjectUsesOwnedIdentityDTO(t *testing.T) {
|
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+project.Identity, 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.Equal(t, project.Identity, payload["id"])
|
|
require.ElementsMatch(t, []string{"id", "name", "identifier", "icon", "background", "description"}, mapKeys(payload))
|
|
}
|
|
|
|
func TestListProjectsUsesIdentityDTO(t *testing.T) {
|
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", 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.Len(t, payload, 1)
|
|
require.Equal(t, project.Identity, payload[0]["id"])
|
|
require.ElementsMatch(t, []string{"id", "name", "identifier", "icon", "background", "description"}, mapKeys(payload[0]))
|
|
}
|
|
|
|
func TestUpdateProject(t *testing.T) {
|
|
t.Run("updates the allowed project fields by identity", func(t *testing.T) {
|
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
body, err := json.Marshal(gin.H{
|
|
"name": "Alpha Next",
|
|
"identifier": "ALPHA-NEXT",
|
|
"icon": "tree",
|
|
"background": "#0FC6C2",
|
|
"description": "更新后的项目说明",
|
|
})
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+project.Identity, bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusNoContent, rec.Code)
|
|
require.Empty(t, rec.Body.String())
|
|
var updated models.SenlinAgentProject
|
|
require.NoError(t, models.DBService.First(&updated, project.ID).Error)
|
|
require.Equal(t, "Alpha Next", updated.Name)
|
|
require.Equal(t, "ALPHA-NEXT", updated.Identifier)
|
|
require.Equal(t, "tree", updated.Icon)
|
|
require.Equal(t, "#0FC6C2", updated.Background)
|
|
require.Equal(t, "更新后的项目说明", updated.Description)
|
|
})
|
|
|
|
t.Run("clears optional metadata while preserving omitted name and identifier", func(t *testing.T) {
|
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
require.NoError(t, models.DBService.Model(project).Updates(map[string]any{
|
|
"icon": "tree",
|
|
"background": "#165DFF",
|
|
"description": "原项目说明",
|
|
}).Error)
|
|
originalName := project.Name
|
|
originalIdentifier := project.Identifier
|
|
body, err := json.Marshal(gin.H{
|
|
"icon": "",
|
|
"background": "",
|
|
"description": "",
|
|
})
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+project.Identity, bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusNoContent, rec.Code)
|
|
var updated models.SenlinAgentProject
|
|
require.NoError(t, models.DBService.First(&updated, project.ID).Error)
|
|
require.Equal(t, originalName, updated.Name)
|
|
require.Equal(t, originalIdentifier, updated.Identifier)
|
|
require.Empty(t, updated.Icon)
|
|
require.Empty(t, updated.Background)
|
|
require.Empty(t, updated.Description)
|
|
})
|
|
|
|
t.Run("returns not found when the identity is not owned by the current user", func(t *testing.T) {
|
|
router, _, _ := newProjectsHandlerTestRouter(t)
|
|
other, err := NewService().CreateProject(2, "Other", "")
|
|
require.NoError(t, err)
|
|
body, err := json.Marshal(gin.H{"name": "不可见项目"})
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+other.Identity, bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, rec.Code)
|
|
var payload httpx.ErrorEnvelope
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
|
require.Equal(t, "not_found", payload.Error.Code)
|
|
require.Equal(t, "项目不存在", payload.Error.Message)
|
|
})
|
|
|
|
t.Run("returns conflict for another owned project identifier", func(t *testing.T) {
|
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
_, err := NewService().CreateProjectWithInput(1, CreateProjectRequest{Name: "Beta", Identifier: "BETA"})
|
|
require.NoError(t, err)
|
|
body, err := json.Marshal(gin.H{"identifier": "BETA"})
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+project.Identity, bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusConflict, rec.Code)
|
|
var payload httpx.ErrorEnvelope
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
|
require.Equal(t, "conflict", payload.Error.Code)
|
|
require.Equal(t, "项目标识已存在", payload.Error.Message)
|
|
})
|
|
}
|
|
|
|
func TestCreateTaskHandlerPersistsTask(t *testing.T) {
|
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z", "tag": "需求"})
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1/tasks", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusCreated, rec.Code)
|
|
var task models.SenlinAgentTask
|
|
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "整理需求").First(&task).Error)
|
|
require.Equal(t, "open", task.Status)
|
|
require.NotNil(t, task.DueAt)
|
|
require.NotNil(t, task.TagID)
|
|
}
|
|
|
|
func TestUploadSourceHandlerStoresFileAndSource(t *testing.T) {
|
|
router, project, storageDir := newProjectsHandlerTestRouter(t)
|
|
body := &bytes.Buffer{}
|
|
writer := multipart.NewWriter(body)
|
|
require.NoError(t, writer.WriteField("title", "客户资料.txt"))
|
|
fileWriter, err := writer.CreateFormFile("file", "客户资料.txt")
|
|
require.NoError(t, err)
|
|
_, err = fileWriter.Write([]byte("hello"))
|
|
require.NoError(t, err)
|
|
require.NoError(t, writer.Close())
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1/sources", body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusCreated, rec.Code)
|
|
var source models.SenlinAgentSource
|
|
require.NoError(t, models.DBService.Where("project_id = ? AND kind = ?", project.ID, "file").First(&source).Error)
|
|
require.Equal(t, "客户资料.txt", source.Title)
|
|
require.NotEmpty(t, source.FilePath)
|
|
require.FileExists(t, filepath.Join(storageDir, source.FilePath))
|
|
}
|
|
|
|
func TestCreateCronPlanHandlerPersistsPlan(t *testing.T) {
|
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
body, err := json.Marshal(gin.H{"title": "每日整理", "schedule": "0 9 * * *", "enabled": true, "nextRunAt": "2026-07-22T08:00:00Z"})
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1/cron-plans", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusCreated, rec.Code)
|
|
var plan models.SenlinAgentCronPlan
|
|
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "每日整理").First(&plan).Error)
|
|
require.Equal(t, "0 9 * * *", plan.Schedule)
|
|
require.True(t, plan.Enabled)
|
|
require.NotNil(t, plan.NextRunAt)
|
|
}
|
|
|
|
func TestCreateTagHandlerPersistsProjectTag(t *testing.T) {
|
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
body, err := json.Marshal(gin.H{"name": "Design"})
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1/tags", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusCreated, rec.Code)
|
|
var tag models.SenlinAgentTag
|
|
require.NoError(t, models.DBService.Where("project_id = ? AND name = ?", project.ID, "Design").First(&tag).Error)
|
|
require.Equal(t, project.ID, tag.ProjectID)
|
|
}
|
|
|
|
func TestListTagsHandlerReturnsProjectTags(t *testing.T) {
|
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
require.NoError(t, models.DBService.Create(&models.SenlinAgentTag{ProjectID: project.ID, Name: "Design"}).Error)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/1/tags", nil)
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
var tags []models.SenlinAgentTag
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &tags))
|
|
require.Len(t, tags, 1)
|
|
require.Equal(t, "Design", tags[0].Name)
|
|
}
|
|
|
|
func TestUpdateTaskHandlerPersistsTagAndStatus(t *testing.T) {
|
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
task := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: 1, Title: "Draft", Status: "open"}
|
|
require.NoError(t, models.DBService.Create(&task).Error)
|
|
body, err := json.Marshal(gin.H{"title": "Draft v2", "description": "Updated", "completed": true, "tag": "Important"})
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/1/tasks/1", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
var updated models.SenlinAgentTask
|
|
require.NoError(t, models.DBService.First(&updated, task.ID).Error)
|
|
require.Equal(t, "Draft v2", updated.Title)
|
|
require.Equal(t, "done", updated.Status)
|
|
require.NotNil(t, updated.TagID)
|
|
}
|
|
|
|
func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgentProject, string) {
|
|
t.Helper()
|
|
newTestDB(t)
|
|
require.NoError(t, models.DBService.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
|
|
service := NewService()
|
|
project, err := service.CreateProject(1, "Alpha", "")
|
|
require.NoError(t, err)
|
|
storageDir := t.TempDir()
|
|
router := httpx.NewProtectedRouter(
|
|
config.Config{Env: "test"},
|
|
func(token string) (uint, error) { return 1, nil },
|
|
NewHandler(service, files.NewService(storageDir)),
|
|
)
|
|
return router, project, storageDir
|
|
}
|
|
|
|
func mapKeys(value map[string]any) []string {
|
|
keys := make([]string, 0, len(value))
|
|
for key := range value {
|
|
keys = append(keys, key)
|
|
}
|
|
return keys
|
|
}
|