package projects import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "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/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.SaProject 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.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.SaProject 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.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.SaProject 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 newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SaProject, string) { t.Helper() newTestDB(t) require.NoError(t, models.DBService.Create(&models.SaUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error) service := NewService() project, err := service.CreateProject(1, "Alpha", "") require.NoError(t, err) router := httpx.NewProtectedRouter( config.Config{Env: "test"}, func(token string) (uint, error) { return 1, nil }, NewHandler(service), ) return router, project, "" } func mapKeys(value map[string]any) []string { keys := make([]string, 0, len(value)) for key := range value { keys = append(keys, key) } return keys }