From 2c9b4777aeb84d8e248f9ba96534b7a9b9ddbe02 Mon Sep 17 00:00:00 2001 From: huang <1724659546@qq.com> Date: Thu, 30 Oct 2025 16:10:10 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=9F=E6=88=90openspace=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../device/device_controller_test.go | 741 ---------------- .../controller/plan/plan_controller_test.go | 827 ------------------ .../controller/user/user_controller_test.go | 450 ---------- .../refactor-migrate-gin-to-echo/design.md | 78 ++ .../refactor-migrate-gin-to-echo/proposal.md | 26 + .../specs/http-server/spec.md | 17 + .../refactor-migrate-gin-to-echo/tasks.md | 60 ++ 7 files changed, 181 insertions(+), 2018 deletions(-) delete mode 100644 internal/app/controller/device/device_controller_test.go delete mode 100644 internal/app/controller/plan/plan_controller_test.go delete mode 100644 internal/app/controller/user/user_controller_test.go create mode 100644 openspec/changes/refactor-migrate-gin-to-echo/design.md create mode 100644 openspec/changes/refactor-migrate-gin-to-echo/proposal.md create mode 100644 openspec/changes/refactor-migrate-gin-to-echo/specs/http-server/spec.md create mode 100644 openspec/changes/refactor-migrate-gin-to-echo/tasks.md diff --git a/internal/app/controller/device/device_controller_test.go b/internal/app/controller/device/device_controller_test.go deleted file mode 100644 index 7bb1239..0000000 --- a/internal/app/controller/device/device_controller_test.go +++ /dev/null @@ -1,741 +0,0 @@ -package device_test - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "testing" - "time" - - "git.huangwc.com/pig/pig-farm-controller/internal/app/controller" - "git.huangwc.com/pig/pig-farm-controller/internal/app/controller/device" - "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" - "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "gorm.io/datatypes" - "gorm.io/gorm" -) - -// MockDeviceRepository 是 DeviceRepository 接口的模拟实现 -type MockDeviceRepository struct { - mock.Mock -} - -// CreateTx 模拟 DeviceRepository 的 CreateTx 方法 -func (m *MockDeviceRepository) Create(device *models.Device) error { - args := m.Called(device) - return args.Error(0) -} - -// FindByID 模拟 DeviceRepository 的 FindByID 方法 -func (m *MockDeviceRepository) FindByID(id uint) (*models.Device, error) { - args := m.Called(id) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*models.Device), args.Error(1) -} - -// FindByIDString 模拟 DeviceRepository 的 FindByIDString 方法 -func (m *MockDeviceRepository) FindByIDString(id string) (*models.Device, error) { - args := m.Called(id) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*models.Device), args.Error(1) -} - -// ListAll 模拟 DeviceRepository 的 ListAll 方法 -func (m *MockDeviceRepository) ListAll() ([]*models.Device, error) { - args := m.Called() - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]*models.Device), args.Error(1) -} - -// ListByParentID 模拟 DeviceRepository 的 ListByParentID 方法 -func (m *MockDeviceRepository) ListByParentID(parentID *uint) ([]*models.Device, error) { - args := m.Called(parentID) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]*models.Device), args.Error(1) -} - -// Update 模拟 DeviceRepository 的 Update 方法 -func (m *MockDeviceRepository) Update(device *models.Device) error { - args := m.Called(device) - return args.Error(0) -} - -// Delete 模拟 DeviceRepository 的 Delete 方法 -func (m *MockDeviceRepository) Delete(id uint) error { - args := m.Called(id) - return args.Error(0) -} - -// testCase 结构体定义了所有测试用例的通用参数 -type testCase struct { - name string - httpMethod string // 新增字段:HTTP 方法 - requestBody interface{} - paramID string // URL 中的 ID 参数 - mockRepoSetup func(*MockDeviceRepository) - expectedStatus int // HTTP 状态码 - expectedCode int // 业务状态码 - expectedMessage string - expectedDataFunc func(interface{}) bool // 用于验证 data 字段的函数 -} - -// runTest 是一个辅助函数,用于执行单个测试用例 -func runTest(t *testing.T, tc testCase, controllerMethod func(*gin.Context, *MockDeviceRepository)) { - // 初始化 Gin 上下文 - w := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(w) - - // 设置请求体和 HTTP 方法 - if tc.requestBody != nil { - jsonBody, _ := json.Marshal(tc.requestBody) - ctx.Request = httptest.NewRequest(tc.httpMethod, "/", io.NopCloser(bytes.NewBuffer(jsonBody))) - ctx.Request.Header.Set("Content-Type", "application/json") - } else { - // 对于没有请求体的请求 (GET, DELETE, 或没有 body 的 POST/PUT) - ctx.Request = httptest.NewRequest(tc.httpMethod, "/", nil) - } - - // 设置 URL 参数 - if tc.paramID != "" { - ctx.Params = append(ctx.Params, gin.Param{Key: "id", Value: tc.paramID}) - } - - // 创建 Mock Repository - mockRepo := new(MockDeviceRepository) - // 设置 Mock 行为 - tc.mockRepoSetup(mockRepo) - - // 调用被测试的方法,并传入 mockRepo - controllerMethod(ctx, mockRepo) - - // 解析响应体 - var responseBody controller.Response - err := json.Unmarshal(w.Body.Bytes(), &responseBody) - assert.NoError(t, err) - - // 断言 HTTP 状态码始终为 200 OK - assert.Equal(t, tc.expectedStatus, w.Code) - - // 断言业务状态码和消息 - assert.Equal(t, tc.expectedCode, responseBody.Code) - assert.Equal(t, tc.expectedMessage, responseBody.Message) - - // 断言数据字段 - if tc.expectedDataFunc != nil { - var data interface{} - // 只有当 responseBody.Data 不为 nil 且其底层类型为 []byte 时才尝试 Unmarshal - if responseBody.Data != nil { - if byteData, ok := responseBody.Data.([]byte); ok { - err = json.Unmarshal(byteData, &data) - assert.NoError(t, err, "无法解析响应数据") // 增加对 Unmarshal 错误的断言 - } else { - // 如果 Data 不为 nil 但也不是 []byte,这通常不应该发生 - // 但为了健壮性,直接将原始 interface{} 赋值给 data - data = responseBody.Data - } - } - assert.True(t, tc.expectedDataFunc(data), "数据字段验证失败") - } - - // 验证 Mock 期望是否都已满足 - mockRepo.AssertExpectations(t) -} - -func TestCreateDevice(t *testing.T) { - gin.SetMode(gin.TestMode) - - tests := []testCase{ - { - name: "成功创建区域主控", - httpMethod: http.MethodPost, - requestBody: device.CreateDeviceRequest{ - Name: "主控A", - Type: models.DeviceTypeAreaController, - Location: "猪舍1", - Properties: controller.Properties(`{"lora_address":"0x1234"}`), - }, - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("CreateTx", mock.MatchedBy(func(dev *models.Device) bool { - // 检查 Name 字段 - nameMatch := dev.Name == "主控A" - // 检查 Type 字段 - typeMatch := dev.Type == models.DeviceTypeAreaController - // 检查 Location 字段 - locationMatch := dev.Location == "猪舍1" - // 检查 Properties 字段的字节内容 - expectedProperties := controller.Properties(`{"lora_address":"0x1234"}`) - propertiesMatch := bytes.Equal(dev.Properties, expectedProperties) - - return nameMatch && typeMatch && locationMatch && propertiesMatch - })).Return(nil).Run(func(args mock.Arguments) { - // 模拟 GORM 自动填充 ID - arg := args.Get(0).(*models.Device) - arg.ID = 1 - arg.CreatedAt = time.Now() - arg.UpdatedAt = time.Now() - }).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeCreated, - expectedMessage: "设备创建成功", - expectedDataFunc: func(data interface{}) bool { - dataMap, ok := data.(map[string]interface{}) - if !ok { - return false - } - return dataMap["id"] != nil && - dataMap["name"] == "主控A" && - dataMap["type"] == string(models.DeviceTypeAreaController) && - dataMap["properties"] != nil - }, - }, - { - name: "成功创建普通设备", - httpMethod: http.MethodPost, - requestBody: device.CreateDeviceRequest{ - Name: "温度传感器", - Type: models.DeviceTypeDevice, - SubType: models.SubTypeSensorTemp, - ParentID: func() *uint { id := uint(1); return &id }(), - Location: "猪舍1-A区", - Properties: controller.Properties(`{"bus_id":1,"bus_address":10}`), - }, - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("CreateTx", mock.Anything).Return(nil).Run(func(args mock.Arguments) { - arg := args.Get(0).(*models.Device) - arg.ID = 2 - arg.CreatedAt = time.Now() - arg.UpdatedAt = time.Now() - }).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeCreated, - expectedMessage: "设备创建成功", - expectedDataFunc: func(data interface{}) bool { - dataMap, ok := data.(map[string]interface{}) - if !ok { - return false - } - return dataMap["id"] != nil && - dataMap["name"] == "温度传感器" && - dataMap["type"] == string(models.DeviceTypeDevice) && - dataMap["sub_type"] == string(models.SubTypeSensorTemp) && - dataMap["parent_id"] != nil && - dataMap["properties"] != nil - }, - }, - { - name: "请求参数绑定失败", - httpMethod: http.MethodPost, - requestBody: device.CreateDeviceRequest{ - Name: "", // 缺少必填字段 Name - Type: models.DeviceTypeAreaController, - }, - mockRepoSetup: func(m *MockDeviceRepository) {}, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeBadRequest, - expectedMessage: "Key: 'CreateDeviceRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - { - name: "数据库创建失败", - httpMethod: http.MethodPost, - requestBody: device.CreateDeviceRequest{ - Name: "失败设备", - Type: models.DeviceTypeDevice, - }, - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("CreateTx", mock.Anything).Return(errors.New("db error")).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeInternalError, - expectedMessage: "创建设备失败", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - // 新增:Properties字段JSON格式无效 - { - name: "Properties字段JSON格式无效", - httpMethod: http.MethodPost, - requestBody: device.CreateDeviceRequest{ - Name: "无效JSON设备", - Type: models.DeviceTypeDevice, - Properties: controller.Properties(`{invalid json}`), - }, - mockRepoSetup: func(m *MockDeviceRepository) { - // 期望 CreateTx 方法被调用,并返回一个模拟的数据库错误 - // 这个错误模拟的是数据库层因为 Properties 字段的 JSON 格式无效而拒绝保存 - m.On("CreateTx", mock.Anything).Return(errors.New("database error: invalid json format")).Run(func(args mock.Arguments) { - dev := args.Get(0).(*models.Device) - assert.Equal(t, "无效JSON设备", dev.Name) - assert.Equal(t, models.DeviceTypeDevice, dev.Type) - expectedProperties := controller.Properties(`{invalid json}`) - assert.True(t, bytes.Equal(dev.Properties, expectedProperties), "Properties should match") - }).Once() - }, - expectedStatus: http.StatusOK, // HTTP status is 200 OK for business errors - expectedCode: controller.CodeInternalError, // Business code for internal server error - expectedMessage: "创建设备失败", // The message returned by the controller - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) { - device.NewController(repo, logs.NewSilentLogger()).CreateDevice(ctx) - }) - }) - } -} - -func TestGetDevice(t *testing.T) { - gin.SetMode(gin.TestMode) - - tests := []testCase{ - { - name: "成功获取设备", - httpMethod: http.MethodGet, - requestBody: nil, - paramID: "1", - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("FindByIDString", "1").Return(&models.Device{ - Model: gorm.Model{ - ID: 1, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }, - Name: "测试设备", - Type: models.DeviceTypeAreaController, - Location: "测试地点", - Properties: datatypes.JSON(`{"key":"value"}`), - }, nil).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeSuccess, - expectedMessage: "获取设备信息成功", - expectedDataFunc: func(data interface{}) bool { - dataMap, ok := data.(map[string]interface{}) - if !ok { - return false - } - return dataMap["id"] == float64(1) && - dataMap["name"] == "测试设备" && - dataMap["properties"] != nil - }, - }, - { - name: "设备未找到", - httpMethod: http.MethodGet, - requestBody: nil, - paramID: "999", - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("FindByIDString", "999").Return(nil, gorm.ErrRecordNotFound).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeNotFound, - expectedMessage: "设备未找到", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - { - name: "ID格式无效", - httpMethod: http.MethodGet, - requestBody: nil, - paramID: "abc", - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("FindByIDString", "abc").Return(nil, errors.New("无效的设备ID格式")).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeBadRequest, - expectedMessage: "无效的设备ID格式", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - { - name: "数据库查询失败", - httpMethod: http.MethodGet, - requestBody: nil, - paramID: "1", - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("FindByIDString", "1").Return(nil, errors.New("db error")).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeInternalError, - expectedMessage: "获取设备信息失败", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) { - device.NewController(repo, logs.NewSilentLogger()).GetDevice(ctx) - }) - }) - } -} - -func TestListDevices(t *testing.T) { - gin.SetMode(gin.TestMode) - - tests := []testCase{ - { - name: "成功获取空列表", - httpMethod: http.MethodGet, - requestBody: nil, - paramID: "", - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("ListAll").Return([]*models.Device{}, nil).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeSuccess, - expectedMessage: "获取设备列表成功", - expectedDataFunc: func(data interface{}) bool { - s, ok := data.([]interface{}) - return ok && len(s) == 0 - }, - }, - { - name: "成功获取包含设备的列表", - httpMethod: http.MethodGet, - requestBody: nil, - paramID: "", - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("ListAll").Return([]*models.Device{ - { - Model: gorm.Model{ - ID: 1, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }, - Name: "设备1", - Type: models.DeviceTypeAreaController, - }, - { - Model: gorm.Model{ - ID: 2, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }, - Name: "设备2", - Type: models.DeviceTypeDevice, - SubType: models.SubTypeFan, - ParentID: func() *uint { id := uint(1); return &id }(), - }, - }, nil).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeSuccess, - expectedMessage: "获取设备列表成功", - expectedDataFunc: func(data interface{}) bool { - dataList, ok := data.([]interface{}) - if !ok { - return false - } - // 检查长度 - if len(dataList) != 2 { - return false - } - // 检查第一个设备 - item1, ok1 := dataList[0].(map[string]interface{}) - if !ok1 || item1["id"] != float64(1) || item1["name"] != "设备1" { - return false - } - // 检查第二个设备 - item2, ok2 := dataList[1].(map[string]interface{}) - if !ok2 || item2["id"] != float64(2) || item2["name"] != "设备2" { - return false - } - return true - }, - }, - { - name: "数据库查询失败", - httpMethod: http.MethodGet, - requestBody: nil, - paramID: "", - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("ListAll").Return(nil, errors.New("db error")).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeInternalError, - expectedMessage: "获取设备列表失败", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) { - device.NewController(repo, logs.NewSilentLogger()).ListDevices(ctx) - }) - }) - } -} - -func TestUpdateDevice(t *testing.T) { - gin.SetMode(gin.TestMode) - - tests := []testCase{ - { - name: "成功更新设备", - httpMethod: http.MethodPut, - requestBody: device.UpdateDeviceRequest{ - Name: "更新后的主控", - Type: models.DeviceTypeAreaController, - Location: "新地点", - Properties: controller.Properties(`{"lora_address":"0x5678"}`), - }, - paramID: "1", - mockRepoSetup: func(m *MockDeviceRepository) { - // 模拟 FindByIDString 找到设备 - m.On("FindByIDString", "1").Return(&models.Device{ - Model: gorm.Model{ - ID: 1, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }, - Name: "旧主控", - Type: models.DeviceTypeAreaController, - Location: "旧地点", - Properties: datatypes.JSON(`{"lora_address":"0x1234"}`), - }, nil).Once() - // 模拟 Update 成功 - m.On("Update", mock.AnythingOfType("*models.Device")).Return(nil).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeSuccess, - expectedMessage: "设备更新成功", - expectedDataFunc: func(data interface{}) bool { - dataMap, ok := data.(map[string]interface{}) - if !ok { - return false - } - return dataMap["id"] == float64(1) && - dataMap["name"] == "更新后的主控" && - dataMap["location"] == "新地点" && - dataMap["properties"] != nil - }, - }, - { - name: "请求参数绑定失败", - httpMethod: http.MethodPut, - requestBody: device.UpdateDeviceRequest{ - Name: "", // 缺少必填字段 Name - Type: models.DeviceTypeAreaController, - }, - paramID: "1", - mockRepoSetup: func(m *MockDeviceRepository) { - // 模拟 FindByIDString 找到设备,以便进入参数绑定阶段 - m.On("FindByIDString", "1").Return(&models.Device{Model: gorm.Model{ID: 1}}, nil).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeBadRequest, - expectedMessage: "Key: 'UpdateDeviceRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - { - name: "设备未找到", - httpMethod: http.MethodPut, - requestBody: device.UpdateDeviceRequest{ - Name: "任意名称", Type: models.DeviceTypeAreaController, - }, - paramID: "999", - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("FindByIDString", "999").Return(nil, gorm.ErrRecordNotFound).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeNotFound, - expectedMessage: "设备未找到", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - { - name: "ID格式无效", - httpMethod: http.MethodPut, - requestBody: device.UpdateDeviceRequest{ - Name: "任意名称", Type: models.DeviceTypeAreaController, - }, - paramID: "abc", - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("FindByIDString", "abc").Return(nil, errors.New("无效的设备ID格式")).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeBadRequest, - expectedMessage: "无效的设备ID格式", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - { - name: "数据库更新失败", - httpMethod: http.MethodPut, - requestBody: device.UpdateDeviceRequest{ - Name: "更新失败设备", Type: models.DeviceTypeAreaController, - }, - paramID: "1", - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("FindByIDString", "1").Return(&models.Device{Model: gorm.Model{ID: 1}}, nil).Once() - m.On("Update", mock.AnythingOfType("*models.Device")).Return(errors.New("db error")).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeInternalError, - expectedMessage: "更新设备失败", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - // 新增:Properties字段JSON格式无效 - { - name: "Properties字段JSON格式无效", - httpMethod: http.MethodPut, - requestBody: device.UpdateDeviceRequest{ - Name: "无效JSON设备", - Type: models.DeviceTypeDevice, - Properties: controller.Properties(`{invalid json}`), - }, - paramID: "1", - mockRepoSetup: func(m *MockDeviceRepository) { - // 模拟 FindByIDString 找到设备,以便进入参数绑定阶段 - m.On("FindByIDString", "1").Return(&models.Device{Model: gorm.Model{ID: 1}}, nil).Once() - // 期望 Update 方法被调用,并返回一个模拟的数据库错误 - m.On("Update", mock.Anything).Return(errors.New("database error: invalid json format")).Run(func(args mock.Arguments) { - dev := args.Get(0).(*models.Device) - assert.Equal(t, "无效JSON设备", dev.Name) - assert.Equal(t, models.DeviceTypeDevice, dev.Type) - expectedProperties := controller.Properties(`{invalid json}`) - assert.True(t, bytes.Equal(dev.Properties, expectedProperties), "Properties should match") - }).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeInternalError, // Expected to be internal server error due to DB error - expectedMessage: "更新设备失败", // The message returned by the controller - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - // 新增:成功更新设备的ParentID - { - name: "成功更新设备的ParentID", - httpMethod: http.MethodPut, - requestBody: device.UpdateDeviceRequest{ - Name: "更新ParentID设备", - Type: models.DeviceTypeDevice, - ParentID: func() *uint { id := uint(10); return &id }(), - Location: "新地点", - Properties: controller.Properties(`{"key":"value"}`), - }, - paramID: "1", - mockRepoSetup: func(m *MockDeviceRepository) { - // 模拟 FindByIDString 找到设备 - m.On("FindByIDString", "1").Return(&models.Device{ - Model: gorm.Model{ - ID: 1, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }, - Name: "旧设备", - Type: models.DeviceTypeDevice, - ParentID: func() *uint { id := uint(1); return &id }(), - Location: "旧地点", - Properties: datatypes.JSON(`{"old_key":"old_value"}`), - }, nil).Once() - // 模拟 Update 成功,并验证 ParentID 被更新 - m.On("Update", mock.MatchedBy(func(dev *models.Device) bool { - return dev.ID == 1 && *dev.ParentID == 10 - })).Return(nil).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeSuccess, - expectedMessage: "设备更新成功", - expectedDataFunc: func(data interface{}) bool { - dataMap, ok := data.(map[string]interface{}) - if !ok { - return false - } - return dataMap["id"] == float64(1) && - dataMap["parent_id"] == float64(10) && - dataMap["properties"] != nil - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) { - device.NewController(repo, logs.NewSilentLogger()).UpdateDevice(ctx) - }) - }) - } -} - -func TestDeleteDevice(t *testing.T) { - gin.SetMode(gin.TestMode) - - tests := []testCase{ - { - name: "成功删除设备", - httpMethod: http.MethodDelete, - requestBody: nil, - paramID: "1", - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("Delete", uint(1)).Return(nil).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeSuccess, - expectedMessage: "设备删除成功", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - { - name: "ID格式无效", - httpMethod: http.MethodDelete, - requestBody: nil, - paramID: "abc", - mockRepoSetup: func(m *MockDeviceRepository) {}, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeBadRequest, - expectedMessage: "无效的设备ID格式", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - { - name: "数据库删除失败", - httpMethod: http.MethodDelete, - requestBody: nil, - paramID: "1", - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("Delete", uint(1)).Return(errors.New("db error")).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeInternalError, - expectedMessage: "删除设备失败", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - // 新增:删除设备未找到 - { - name: "删除设备未找到", - httpMethod: http.MethodDelete, - requestBody: nil, - paramID: "999", - mockRepoSetup: func(m *MockDeviceRepository) { - m.On("Delete", uint(999)).Return(gorm.ErrRecordNotFound).Once() - }, - expectedStatus: http.StatusOK, - expectedCode: controller.CodeInternalError, // 当前控制器逻辑会将 ErrRecordNotFound 视为内部错误 - expectedMessage: "删除设备失败", - expectedDataFunc: func(data interface{}) bool { return data == nil }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) { - device.NewController(repo, logs.NewSilentLogger()).DeleteDevice(ctx) - }) - }) - } -} diff --git a/internal/app/controller/plan/plan_controller_test.go b/internal/app/controller/plan/plan_controller_test.go deleted file mode 100644 index 628a1f7..0000000 --- a/internal/app/controller/plan/plan_controller_test.go +++ /dev/null @@ -1,827 +0,0 @@ -package plan - -import ( - "bytes" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strconv" - "testing" - - "git.huangwc.com/pig/pig-farm-controller/internal/app/controller" - "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" - "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" - "git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "gorm.io/gorm" -) - -// MockPlanRepository 是 repository.PlanRepository 的一个模拟实现,用于测试 -type MockPlanRepository struct { - // CreatePlanFunc 模拟 CreatePlan 方法的行为 - CreatePlanFunc func(plan *models.Plan) error - // GetPlanByIDFunc 模拟 GetPlanByID 方法的行为 - GetPlanByIDFunc func(id uint) (*models.Plan, error) - // GetBasicPlanByIDFunc 模拟 GetBasicPlanByID 方法的行为 - GetBasicPlanByIDFunc func(id uint) (*models.Plan, error) - // ListBasicPlansFunc 模拟 ListBasicPlans 方法的行为 - ListBasicPlansFunc func() ([]models.Plan, error) - // UpdatePlanFunc 模拟 UpdatePlan 方法的行为 - UpdatePlanFunc func(plan *models.Plan) error - // DeletePlanFunc 模拟 DeletePlan 方法的行为 - DeletePlanFunc func(id uint) error -} - -// ListBasicPlans 实现了 MockPlanRepository 接口的 ListBasicPlans 方法 -func (m *MockPlanRepository) ListBasicPlans() ([]models.Plan, error) { - return m.ListBasicPlansFunc() -} - -// GetBasicPlanByID 实现了 MockPlanRepository 接口的 GetBasicPlanByID 方法 -func (m *MockPlanRepository) GetBasicPlanByID(id uint) (*models.Plan, error) { - return m.GetBasicPlanByIDFunc(id) -} - -// GetPlanByID 实现了 MockPlanRepository 接口的 GetPlanByID 方法 -func (m *MockPlanRepository) GetPlanByID(id uint) (*models.Plan, error) { - return m.GetPlanByIDFunc(id) -} - -// CreatePlan 实现了 MockPlanRepository 接口的 CreatePlan 方法 -func (m *MockPlanRepository) CreatePlan(plan *models.Plan) error { - return m.CreatePlanFunc(plan) -} - -// UpdatePlan 实现了 MockPlanRepository 接口的 UpdatePlan 方法 -func (m *MockPlanRepository) UpdatePlan(plan *models.Plan) error { - return m.UpdatePlanFunc(plan) -} - -// DeletePlan 实现了 MockPlanRepository 接口的 DeletePlan 方法 -func (m *MockPlanRepository) DeletePlan(id uint) error { - return m.DeletePlanFunc(id) -} - -// setupTestRouter 创建一个用于测试的 gin 引擎和控制器实例 -func setupTestRouter(repo repository.PlanRepository) *gin.Engine { - gin.SetMode(gin.TestMode) - router := gin.Default() - logger := logs.NewSilentLogger() - planController := NewController(logger, repo) - router.POST("/plans", planController.CreatePlan) - router.GET("/plans/:id", planController.GetPlan) - router.GET("/plans", planController.ListPlans) - router.PUT("/plans/:id", planController.UpdatePlan) - router.DELETE("/plans/:id", planController.DeletePlan) - return router -} - -// TestController_CreatePlan 测试 CreatePlan 方法 -func TestController_CreatePlan(t *testing.T) { - t.Run("成功-创建包含任务的计划", func(t *testing.T) { - // Arrange (准备阶段) - // 模拟仓库行为:CreatePlan 成功时,为计划和任务分配ID - mockRepo := &MockPlanRepository{ - CreatePlanFunc: func(plan *models.Plan) error { - plan.ID = 1 - for i := range plan.Tasks { - plan.Tasks[i].ID = uint(i + 1) - plan.Tasks[i].PlanID = plan.ID - } - return nil - }, - } - // 设置 Gin 路由器,并注入模拟仓库 - router := setupTestRouter(mockRepo) - - // 准备请求体 - reqBody := CreatePlanRequest{ - Name: "Test Plan with Tasks", - ExecutionType: models.PlanExecutionTypeManual, - ContentType: models.PlanContentTypeTasks, - Tasks: []TaskRequest{ - {Name: "Task 1", ExecutionOrder: 1, Type: models.TaskTypeWaiting}, - }, - } - bodyBytes, _ := json.Marshal(reqBody) - - // 创建 HTTP 请求 - req, _ := http.NewRequest(http.MethodPost, "/plans", bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - // Act (执行阶段) - // 发送 HTTP 请求到路由器 - router.ServeHTTP(w, req) - - // Assert (断言阶段) - // 验证 HTTP 状态码 - assert.Equal(t, http.StatusOK, w.Code) - - // 解析响应体 - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - // 验证业务响应码和消息 - assert.Equal(t, controller.CodeCreated, resp.Code) - assert.Equal(t, "计划创建成功", resp.Message) - - // 验证返回数据中的计划ID - dataMap, ok := resp.Data.(map[string]interface{}) - assert.True(t, ok) - assert.Equal(t, float64(1), dataMap["id"]) - }) -} - -// TestController_GetPlan 是为 GetPlan 方法新增的单元测试函数 -func TestController_GetPlan(t *testing.T) { - t.Run("成功-获取计划详情", func(t *testing.T) { - // Arrange (准备阶段) - // 模拟仓库行为:GetPlanByID 成功时返回一个计划 - mockRepo := &MockPlanRepository{ - GetPlanByIDFunc: func(id uint) (*models.Plan, error) { - assert.Equal(t, uint(1), id) - return &models.Plan{ - Model: gorm.Model{ID: 1}, - Name: "Test Plan", - ContentType: models.PlanContentTypeTasks, - }, nil - }, - } - // 设置 Gin 路由器 - router := setupTestRouter(mockRepo) - w := httptest.NewRecorder() - // 创建 HTTP 请求 - req, _ := http.NewRequest(http.MethodGet, "/plans/1", nil) - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeSuccess, resp.Code) - dataMap, ok := resp.Data.(map[string]interface{}) - assert.True(t, ok) - assert.Equal(t, float64(1), dataMap["id"]) - }) - - t.Run("成功-获取内容为空的计划详情", func(t *testing.T) { - // Arrange (准备阶段) - // 模拟仓库行为:GetPlanByID 成功时返回一个任务列表为空的计划 - mockRepo := &MockPlanRepository{ - GetPlanByIDFunc: func(id uint) (*models.Plan, error) { - assert.Equal(t, uint(3), id) - return &models.Plan{ - Model: gorm.Model{ID: 3}, - Name: "Empty Plan", - ContentType: models.PlanContentTypeTasks, - Tasks: []models.Task{}, // 任务列表为空 - }, nil - }, - } - router := setupTestRouter(mockRepo) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/plans/3", nil) - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeSuccess, resp.Code) - - dataMap, ok := resp.Data.(map[string]interface{}) - assert.True(t, ok) - assert.Equal(t, float64(3), dataMap["id"]) - assert.Equal(t, "Empty Plan", dataMap["name"]) - - // 关键断言:因为 omitempty 标签,当 tasks 列表为空时,该字段不应该出现在JSON中 - _, ok = dataMap["tasks"] - assert.False(t, ok, "当任务列表为空时,'tasks' 字段因为 omitempty 标签,不应该出现在JSON响应中") - }) - - t.Run("失败-计划不存在", func(t *testing.T) { - // Arrange (准备阶段) - // 模拟仓库行为:GetPlanByID 返回记录未找到错误 - mockRepo := &MockPlanRepository{ - GetPlanByIDFunc: func(id uint) (*models.Plan, error) { - return nil, gorm.ErrRecordNotFound - }, - } - router := setupTestRouter(mockRepo) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/plans/999", nil) - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeNotFound, resp.Code) - assert.Equal(t, "计划不存在", resp.Message) - }) - - t.Run("失败-无效的ID格式", func(t *testing.T) { - // Arrange (准备阶段) - // 模拟仓库为空,因为预期不会调用仓库方法 - mockRepo := &MockPlanRepository{} - router := setupTestRouter(mockRepo) - w := httptest.NewRecorder() - // 创建带有无效ID格式的 HTTP 请求 - req, _ := http.NewRequest(http.MethodGet, "/plans/abc", nil) - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeBadRequest, resp.Code) - assert.Equal(t, "无效的计划ID格式", resp.Message) - }) - - t.Run("失败-仓库层内部错误", func(t *testing.T) { - // Arrange (准备阶段) - internalErr := errors.New("database connection lost") - // 模拟仓库行为:GetPlanByID 返回内部错误 - mockRepo := &MockPlanRepository{ - GetPlanByIDFunc: func(id uint) (*models.Plan, error) { - return nil, internalErr - }, - } - router := setupTestRouter(mockRepo) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/plans/1", nil) - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeInternalError, resp.Code) - assert.Equal(t, "获取计划详情时发生内部错误", resp.Message) - }) -} - -// TestController_ListPlans 测试 ListPlans 方法 -func TestController_ListPlans(t *testing.T) { - t.Run("成功-获取计划列表", func(t *testing.T) { - // Arrange (准备阶段) - // 模拟返回的计划列表 - mockPlans := []models.Plan{ - {Model: gorm.Model{ID: 1}, Name: "Plan 1", ContentType: models.PlanContentTypeTasks}, - {Model: gorm.Model{ID: 2}, Name: "Plan 2", ContentType: models.PlanContentTypeTasks}, - } - // 模拟仓库行为:ListBasicPlans 成功时返回计划列表 - mockRepo := &MockPlanRepository{ - ListBasicPlansFunc: func() ([]models.Plan, error) { - return mockPlans, nil - }, - } - router := setupTestRouter(mockRepo) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/plans", nil) - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeSuccess, resp.Code) - assert.Equal(t, "获取计划列表成功", resp.Message) - - dataBytes, err := json.Marshal(resp.Data) - assert.NoError(t, err) - var listResp ListPlansResponse - err = json.Unmarshal(dataBytes, &listResp) - assert.NoError(t, err) - - assert.Equal(t, 2, listResp.Total) - assert.Len(t, listResp.Plans, 2) - assert.Equal(t, uint(1), listResp.Plans[0].ID) - assert.Equal(t, "Plan 1", listResp.Plans[0].Name) - }) - - t.Run("成功-返回空列表", func(t *testing.T) { - // Arrange (准备阶段) - // 模拟仓库行为:ListBasicPlans 返回空列表 - mockRepo := &MockPlanRepository{ - ListBasicPlansFunc: func() ([]models.Plan, error) { - return []models.Plan{}, nil - }, - } - router := setupTestRouter(mockRepo) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/plans", nil) - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeSuccess, resp.Code) - - dataBytes, err := json.Marshal(resp.Data) - assert.NoError(t, err) - var listResp ListPlansResponse - err = json.Unmarshal(dataBytes, &listResp) - assert.NoError(t, err) - - assert.Equal(t, 0, listResp.Total) - assert.Len(t, listResp.Plans, 0) - }) - - t.Run("失败-仓库层返回错误", func(t *testing.T) { - // Arrange (准备阶段) - dbErr := errors.New("db error") - // 模拟仓库行为:ListBasicPlans 返回数据库错误 - mockRepo := &MockPlanRepository{ - ListBasicPlansFunc: func() ([]models.Plan, error) { - return nil, dbErr - }, - } - router := setupTestRouter(mockRepo) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/plans", nil) - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeInternalError, resp.Code) - assert.Equal(t, "获取计划列表时发生内部错误", resp.Message) - }) -} - -// TestController_UpdatePlan 是 UpdatePlan 的测试函数 -func TestController_UpdatePlan(t *testing.T) { - t.Run("成功-更新计划", func(t *testing.T) { - // Arrange (准备阶段) - planID := uint(1) - updatedName := "Updated Plan Name" - // 模拟一个已存在的计划 - mockPlan := &models.Plan{ - Model: gorm.Model{ID: planID}, - Name: "Original Plan", - Description: "Original Description", - ContentType: models.PlanContentTypeTasks, - } - // 配置模拟仓库的行为 - mockRepo := &MockPlanRepository{ - // 模拟 GetBasicPlanByID 成功返回现有计划 - GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) { - assert.Equal(t, planID, id) - return mockPlan, nil - }, - // 模拟 UpdatePlan 成功更新计划,并更新 mockPlan 的名称 - UpdatePlanFunc: func(plan *models.Plan) error { - assert.Equal(t, planID, plan.ID) - assert.Equal(t, updatedName, plan.Name) - mockPlan.Name = plan.Name // 模拟更新操作 - return nil - }, - // 模拟 GetPlanByID 返回更新后的计划 - GetPlanByIDFunc: func(id uint) (*models.Plan, error) { - assert.Equal(t, planID, id) - return mockPlan, nil // 返回已更新的 mockPlan - }, - } - // 设置 Gin 路由器,并注入模拟仓库 - router := setupTestRouter(mockRepo) - - // 准备更新请求体 - reqBody := UpdatePlanRequest{ - Name: updatedName, - Description: "Updated Description", - ExecutionType: models.PlanExecutionTypeAutomatic, - ContentType: models.PlanContentTypeTasks, - } - bodyBytes, _ := json.Marshal(reqBody) - - // 创建 HTTP PUT 请求 - req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - // Act (执行阶段) - // 发送 HTTP 请求到路由器 - router.ServeHTTP(w, req) - - // Assert (断言阶段) - // 验证 HTTP 状态码 - assert.Equal(t, http.StatusOK, w.Code) - - // 解析响应体 - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - // 验证业务响应码、消息和返回数据 - assert.Equal(t, controller.CodeSuccess, resp.Code) - assert.Equal(t, "计划更新成功", resp.Message) - - dataMap, ok := resp.Data.(map[string]interface{}) - assert.True(t, ok) - assert.Equal(t, float64(planID), dataMap["id"]) - assert.Equal(t, updatedName, dataMap["name"]) - }) - - t.Run("失败-无效的ID格式", func(t *testing.T) { - // Arrange (准备阶段) - // 模拟仓库为空,因为预期不会调用仓库方法 - mockRepo := &MockPlanRepository{} - router := setupTestRouter(mockRepo) - w := httptest.NewRecorder() - // 创建带有无效ID格式的 HTTP PUT 请求 - req, _ := http.NewRequest(http.MethodPut, "/plans/abc", nil) - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeBadRequest, resp.Code) - assert.Equal(t, "无效的计划ID格式", resp.Message) - }) - - t.Run("失败-请求体绑定失败", func(t *testing.T) { - // Arrange (准备阶段) - planID := uint(1) - // 模拟仓库为空,因为预期不会调用仓库方法(请求体绑定失败发生在控制器内部) - mockRepo := &MockPlanRepository{} - router := setupTestRouter(mockRepo) - - // 准备一个无效的 JSON 请求体,例如 execution_type 类型错误 - reqBody := `{\"name\": \"Updated Plan Name\",}` - req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBufferString(reqBody)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeBadRequest, resp.Code) - assert.Contains(t, resp.Message, "无效的请求体") - }) - - t.Run("失败-计划不存在", func(t *testing.T) { - // Arrange (准备阶段) - planID := uint(999) - // 模拟仓库行为:GetBasicPlanByID 返回记录未找到错误 - mockRepo := &MockPlanRepository{ - GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) { - assert.Equal(t, planID, id) - return nil, gorm.ErrRecordNotFound - }, - } - router := setupTestRouter(mockRepo) - - // 准备有效的请求体 - reqBody := UpdatePlanRequest{ - Name: "Updated Plan Name", - Description: "Updated Description", - ExecutionType: models.PlanExecutionTypeAutomatic, - ContentType: models.PlanContentTypeTasks, - } - bodyBytes, _ := json.Marshal(reqBody) - - // 创建 HTTP PUT 请求 - req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeNotFound, resp.Code) - assert.Equal(t, "计划不存在", resp.Message) - }) - - t.Run("失败-计划数据校验失败", func(t *testing.T) { - // Arrange (准备阶段) - planID := uint(1) - // 模拟一个已存在的计划 - mockPlan := &models.Plan{ - Model: gorm.Model{ID: planID}, - Name: "Original Plan", - Description: "Original Description", - ContentType: models.PlanContentTypeTasks, - } - // 配置模拟仓库行为:GetBasicPlanByID 成功返回现有计划 - mockRepo := &MockPlanRepository{ - GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) { - return mockPlan, nil - }, - } - router := setupTestRouter(mockRepo) - - // 准备一个会导致 PlanFromUpdateRequest 校验失败的请求体。 - // 这里通过提供重复的 ExecutionOrder 来触发 ValidateExecutionOrder 错误。 - reqBody := UpdatePlanRequest{ - Name: "Invalid Plan", - ExecutionType: models.PlanExecutionTypeAutomatic, - ContentType: models.PlanContentTypeTasks, // 设置为任务类型 - Tasks: []TaskRequest{ - {Name: "Task 1", ExecutionOrder: 1, Type: models.TaskTypeWaiting}, - {Name: "Task 2", ExecutionOrder: 1, Type: models.TaskTypeWaiting}, // 重复的执行顺序 - }, - } - bodyBytes, _ := json.Marshal(reqBody) - - // 创建 HTTP PUT 请求 - req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeBadRequest, resp.Code) - assert.Contains(t, resp.Message, "计划数据校验失败") - }) - - t.Run("失败-仓库层更新失败", func(t *testing.T) { - // Arrange (准备阶段) - planID := uint(1) - // 模拟一个已存在的计划 - mockPlan := &models.Plan{ - Model: gorm.Model{ID: planID}, - Name: "Original Plan", - Description: "Original Description", - ContentType: models.PlanContentTypeTasks, - } - updateErr := errors.New("failed to update in repository") - // 配置模拟仓库行为 - mockRepo := &MockPlanRepository{ - // 模拟 GetBasicPlanByID 成功返回现有计划 - GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) { - return mockPlan, nil - }, - // 模拟 UpdatePlan 返回更新失败错误 - UpdatePlanFunc: func(plan *models.Plan) error { - return updateErr // 模拟更新失败 - }, - } - router := setupTestRouter(mockRepo) - - // 准备有效的请求体 - reqBody := UpdatePlanRequest{ - Name: "Updated Plan Name", - Description: "Updated Description", - ExecutionType: models.PlanExecutionTypeAutomatic, - ContentType: models.PlanContentTypeTasks, - } - bodyBytes, _ := json.Marshal(reqBody) - - // 创建 HTTP PUT 请求 - req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeBadRequest, resp.Code) - assert.Equal(t, "更新计划失败: "+updateErr.Error(), resp.Message) - }) - - t.Run("失败-获取更新后计划失败", func(t *testing.T) { - // Arrange (准备阶段) - planID := uint(1) - // 模拟一个已存在的计划 - mockPlan := &models.Plan{ - Model: gorm.Model{ID: planID}, - Name: "Original Plan", - Description: "Original Description", - ContentType: models.PlanContentTypeTasks, - } - getUpdatedErr := errors.New("failed to get updated plan from repository") - // 配置模拟仓库行为 - mockRepo := &MockPlanRepository{ - // 模拟 GetBasicPlanByID 成功返回现有计划 - GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) { - return mockPlan, nil - }, - // 模拟 UpdatePlan 成功 - UpdatePlanFunc: func(plan *models.Plan) error { - return nil // 模拟成功更新 - }, - // 模拟 GetPlanByID 返回获取失败错误 - GetPlanByIDFunc: func(id uint) (*models.Plan, error) { - return nil, getUpdatedErr // 模拟获取更新后计划失败 - }, - } - router := setupTestRouter(mockRepo) - - // 准备有效的请求体 - reqBody := UpdatePlanRequest{ - Name: "Updated Plan Name", - Description: "Updated Description", - ExecutionType: models.PlanExecutionTypeAutomatic, - ContentType: models.PlanContentTypeTasks, - } - bodyBytes, _ := json.Marshal(reqBody) - - // 创建 HTTP PUT 请求 - req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeInternalError, resp.Code) - assert.Equal(t, "获取更新后计划详情时发生内部错误", resp.Message) - }) -} - -// TestController_DeletePlan 是 DeletePlan 的单元测试 -func TestController_DeletePlan(t *testing.T) { - t.Run("成功-删除计划", func(t *testing.T) { - // Arrange (准备阶段) - // 模拟仓库行为:DeletePlan 成功 - mockRepo := &MockPlanRepository{ - DeletePlanFunc: func(id uint) error { - assert.Equal(t, uint(1), id) - return nil // 模拟成功删除 - }, - } - router := setupTestRouter(mockRepo) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodDelete, "/plans/1", nil) - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeSuccess, resp.Code) - assert.Equal(t, "计划删除成功", resp.Message) - assert.Nil(t, resp.Data) - }) - - t.Run("失败-计划不存在", func(t *testing.T) { - // Arrange (准备阶段) - // 模拟仓库行为:DeletePlan 返回记录未找到错误 - mockRepo := &MockPlanRepository{ - DeletePlanFunc: func(id uint) error { - return gorm.ErrRecordNotFound // 模拟未找到记录 - }, - } - router := setupTestRouter(mockRepo) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodDelete, "/plans/999", nil) - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeInternalError, resp.Code) - assert.Equal(t, "删除计划时发生内部错误", resp.Message) - }) - - t.Run("失败-无效的ID格式", func(t *testing.T) { - // Arrange (准备阶段) - // 模拟仓库为空,因为预期不会调用仓库方法 - mockRepo := &MockPlanRepository{} - router := setupTestRouter(mockRepo) - w := httptest.NewRecorder() - // 创建带有无效ID格式的 HTTP DELETE 请求 - req, _ := http.NewRequest(http.MethodDelete, "/plans/abc", nil) - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeBadRequest, resp.Code) - assert.Equal(t, "无效的计划ID格式", resp.Message) - }) - - t.Run("失败-仓库层内部错误", func(t *testing.T) { - // Arrange (准备阶段) - internalErr := errors.New("something went wrong") - // 模拟仓库行为:DeletePlan 返回内部错误 - mockRepo := &MockPlanRepository{ - - DeletePlanFunc: func(id uint) error { - return internalErr // 模拟内部错误 - }, - } - router := setupTestRouter(mockRepo) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodDelete, "/plans/1", nil) - - // Act (执行阶段) - router.ServeHTTP(w, req) - - // Assert (断言阶段) - assert.Equal(t, http.StatusOK, w.Code) - - var resp controller.Response - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - - assert.Equal(t, controller.CodeInternalError, resp.Code) - assert.Equal(t, "删除计划时发生内部错误", resp.Message) - }) -} diff --git a/internal/app/controller/user/user_controller_test.go b/internal/app/controller/user/user_controller_test.go deleted file mode 100644 index c447f11..0000000 --- a/internal/app/controller/user/user_controller_test.go +++ /dev/null @@ -1,450 +0,0 @@ -package user_test - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "testing" - - "git.huangwc.com/pig/pig-farm-controller/internal/app/controller" - "git.huangwc.com/pig/pig-farm-controller/internal/app/controller/user" - "git.huangwc.com/pig/pig-farm-controller/internal/app/service/token" - "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" - "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "gorm.io/gorm" -) - -// MockUserRepository 是 UserRepository 接口的模拟实现 -type MockUserRepository struct { - mock.Mock -} - -// CreateTx 模拟 UserRepository 的 CreateTx 方法 -func (m *MockUserRepository) Create(user *models.User) error { - args := m.Called(user) - return args.Error(0) -} - -// FindByUsername 模拟 UserRepository 的 FindByUsername 方法 -// 返回类型改回 *models.User -func (m *MockUserRepository) FindByUsername(username string) (*models.User, error) { - args := m.Called(username) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*models.User), args.Error(1) -} - -// FindByID 模拟 UserRepository 的 FindByID 方法 -func (m *MockUserRepository) FindByID(id uint) (*models.User, error) { - args := m.Called(id) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*models.User), args.Error(1) -} - -// MockTokenService 是 token.TokenService 接口的模拟实现 -type MockTokenService struct { - mock.Mock -} - -// GenerateToken 模拟 TokenService 的 GenerateToken 方法 -func (m *MockTokenService) GenerateToken(userID uint) (string, error) { - args := m.Called(userID) - return args.String(0), args.Error(1) -} - -// ParseToken 模拟 TokenService 的 ParseToken 方法 -func (m *MockTokenService) ParseToken(tokenString string) (*token.Claims, error) { - args := m.Called(tokenString) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*token.Claims), args.Error(1) -} - -// TestCreateUser 测试 CreateUser 方法 -func TestCreateUser(t *testing.T) { - gin.SetMode(gin.TestMode) // 设置 Gin 为测试模式 - - // 创建一个不输出日志的真实 logs.Logger 实例 - silentLogger := logs.NewSilentLogger() - - tests := []struct { - name string - requestBody user.CreateUserRequest - mockRepoSetup func(*MockUserRepository) - expectedResponse map[string]interface{} - }{ - { - name: "成功创建用户", - requestBody: user.CreateUserRequest{ - Username: "testuser", - Password: "password123", - }, - mockRepoSetup: func(m *MockUserRepository) { - // 模拟 CreateTx 成功 - m.On("CreateTx", mock.AnythingOfType("*models.User")).Return(nil).Run(func(args mock.Arguments) { - // 模拟数据库自动填充 ID - userArg := args.Get(0).(*models.User) - userArg.ID = 1 // 设置一个非零的 ID - }).Once() - // 在成功创建用户的路径下,FindByUsername 不会被调用,因此这里不需要设置其期望 - }, - expectedResponse: map[string]interface{}{ - "code": float64(controller.CodeCreated), // 修改这里:使用自定义状态码 - "message": "用户创建成功", - "data": map[string]interface{}{ - "username": "testuser", - // "id": mock.Anything, // 移除这里的 id,在断言时单独检查 - }, - }, - }, - { - name: "请求参数绑定失败_密码过短", - requestBody: user.CreateUserRequest{ - Username: "testuser2", - Password: "123", // 密码少于6位 - }, - mockRepoSetup: func(m *MockUserRepository) { - // 不会调用 CreateTx 或 FindByUsername - }, - expectedResponse: map[string]interface{}{ - "code": float64(controller.CodeBadRequest), - "message": "Key: 'CreateUserRequest.Password' Error:Field validation for 'Password' failed on the 'min' tag", - "data": nil, - }, - }, - { - name: "请求参数绑定失败_缺少用户名", - requestBody: user.CreateUserRequest{ - Password: "password123", - }, - mockRepoSetup: func(m *MockUserRepository) { - // 不会调用 CreateTx 或 FindByUsername - }, - expectedResponse: map[string]interface{}{ - "code": float64(controller.CodeBadRequest), - "message": "Key: 'CreateUserRequest.Username' Error:Field validation for 'Username' failed on the 'required' tag", - "data": nil, - }, - }, - { - name: "用户名已存在", - requestBody: user.CreateUserRequest{ - Username: "existinguser", - Password: "password123", - }, - mockRepoSetup: func(m *MockUserRepository) { - // 模拟 CreateTx 失败,因为用户名已存在 - m.On("CreateTx", mock.AnythingOfType("*models.User")).Return(errors.New("duplicate entry")).Once() - // 模拟 FindByUsername 找到用户,确认是用户名重复 - m.On("FindByUsername", "existinguser").Return(&models.User{Username: "existinguser"}, nil).Once() - }, - expectedResponse: map[string]interface{}{ - "code": float64(controller.CodeConflict), - "message": "用户名已存在", - "data": nil, - }, - }, - { - name: "创建用户失败_通用数据库错误", - requestBody: user.CreateUserRequest{ - Username: "db_error_user", - Password: "password123", - }, - mockRepoSetup: func(m *MockUserRepository) { - // 模拟 CreateTx 失败,通用数据库错误 - m.On("CreateTx", mock.AnythingOfType("*models.User")).Return(errors.New("database error")).Once() - // 模拟 FindByUsername 找不到用户,确认不是用户名重复 - m.On("FindByUsername", "db_error_user").Return(nil, gorm.ErrRecordNotFound).Once() - }, - expectedResponse: map[string]interface{}{ - "code": float64(controller.CodeInternalError), - "message": "创建用户失败", - "data": nil, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // 初始化 Gin 上下文和记录器 - w := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(w) - ctx.Request = httptest.NewRequest(http.MethodPost, "/users", nil) // URL 路径不重要,因为我们不测试路由 - - // 设置请求体 - jsonBody, _ := json.Marshal(tt.requestBody) - ctx.Request.Body = io.NopCloser(bytes.NewBuffer(jsonBody)) - ctx.Request.Header.Set("Content-Type", "application/json") - - // 创建 Mock UserRepository - mockRepo := new(MockUserRepository) - - // 设置 Mock UserRepository 行为 - tt.mockRepoSetup(mockRepo) - - // 创建控制器实例,使用静默日志器 - userController := user.NewController(mockRepo, silentLogger, nil) // tokenService 在 CreateUser 中未使用,设为 nil - - // 调用被测试的方法 - userController.CreateUser(ctx) - - // 解析响应体 - var responseBody map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &responseBody) - assert.NoError(t, err) - - // 断言响应体中的 code 字段 - assert.Equal(t, tt.expectedResponse["code"], responseBody["code"]) - - // 断言响应内容 (除了 code 字段) - if tt.expectedResponse["code"] == float64(controller.CodeCreated) { - // 确保 data 字段存在且是 map[string]interface{} 类型 - data, ok := responseBody["data"].(map[string]interface{}) - assert.True(t, ok, "响应体中的 data 字段应为 map[string]interface{}") - // 确保 id 字段存在且不为零 - id, idOk := data["id"].(float64) - assert.True(t, idOk, "响应体中的 data.id 字段应为 float64 类型") - assert.NotEqual(t, float64(0), id, "响应体中的 data.id 不应为零") - - // 移除 ID 字段以便进行通用断言 - delete(responseBody["data"].(map[string]interface{}), "id") - // 移除 expectedResponse 中的 id 字段,因为我们已经单独验证了 - if expectedData, ok := tt.expectedResponse["data"].(map[string]interface{}); ok { - delete(expectedData, "id") - } - } - // 移除 code 字段以便进行通用断言 - delete(responseBody, "code") - delete(tt.expectedResponse, "code") - assert.Equal(t, tt.expectedResponse, responseBody) - - // 验证 Mock 期望是否都已满足 - mockRepo.AssertExpectations(t) - }) - } -} - -// TestLogin 测试 Login 方法 -func TestLogin(t *testing.T) { - // 设置release模式阻止废话日志 - gin.SetMode(gin.ReleaseMode) - - // 创建一个不输出日志的真实 logs.Logger 实例 - silentLogger := logs.NewSilentLogger() - - tests := []struct { - name string - requestBody user.LoginRequest - mockRepoSetup func(*MockUserRepository) - mockTokenServiceSetup func(*MockTokenService) - expectedResponse map[string]interface{} - }{ - { - name: "成功登录", - requestBody: user.LoginRequest{ - Username: "loginuser", - Password: "correctpassword", - }, - mockRepoSetup: func(m *MockUserRepository) { - mockUser := &models.User{ - Model: gorm.Model{ID: 1}, - Username: "loginuser", - Password: "correctpassword", // 明文密码,BeforeCreate 会哈希它 - } - // 调用 BeforeCreate 钩子来哈希密码 - _ = mockUser.BeforeCreate(nil) - m.On("FindByUsername", "loginuser").Return(mockUser, nil).Once() - }, - mockTokenServiceSetup: func(m *MockTokenService) { - m.On("GenerateToken", uint(1)).Return("mocked_token", nil).Once() - }, - expectedResponse: map[string]interface{}{ - "code": float64(controller.CodeSuccess), - "message": "登录成功", - "data": map[string]interface{}{ - "username": "loginuser", - "id": float64(1), - "token": "mocked_token", - }, - }, - }, - { - name: "请求参数绑定失败_缺少用户名", - requestBody: user.LoginRequest{ - Username: "", // 缺少用户名 - Password: "password", - }, - mockRepoSetup: func(m *MockUserRepository) {}, - mockTokenServiceSetup: func(m *MockTokenService) {}, - expectedResponse: map[string]interface{}{ - "code": float64(controller.CodeBadRequest), - "message": "Key: 'LoginRequest.Username' Error:Field validation for 'Username' failed on the 'required' tag", - "data": nil, - }, - }, - { - name: "请求参数绑定失败_缺少密码", - requestBody: user.LoginRequest{ - Username: "testuser", - Password: "", // 缺少密码 - }, - mockRepoSetup: func(m *MockUserRepository) {}, - mockTokenServiceSetup: func(m *MockTokenService) {}, - expectedResponse: map[string]interface{}{ - "code": float64(controller.CodeBadRequest), - "message": "Key: 'LoginRequest.Password' Error:Field validation for 'Password' failed on the 'required' tag", - "data": nil, - }, - }, - { - name: "用户不存在", - requestBody: user.LoginRequest{ - Username: "nonexistent", - Password: "anypassword", - }, - mockRepoSetup: func(m *MockUserRepository) { - m.On("FindByUsername", "nonexistent").Return(nil, gorm.ErrRecordNotFound).Once() - }, - mockTokenServiceSetup: func(m *MockTokenService) {}, - expectedResponse: map[string]interface{}{ - "code": float64(controller.CodeUnauthorized), - "message": "用户名或密码不正确", - "data": nil, - }, - }, - { - name: "查询用户失败_通用数据库错误", - requestBody: user.LoginRequest{ - Username: "dberroruser", - Password: "password", - }, - mockRepoSetup: func(m *MockUserRepository) { - m.On("FindByUsername", "dberroruser").Return(nil, errors.New("database connection error")).Once() - }, - mockTokenServiceSetup: func(m *MockTokenService) {}, expectedResponse: map[string]interface{}{ - "code": float64(controller.CodeInternalError), - "message": "登录失败", - "data": nil, - }, - }, - { - name: "密码不正确", - requestBody: user.LoginRequest{ - Username: "loginuser", - Password: "wrongpassword", - }, - mockRepoSetup: func(m *MockUserRepository) { - mockUser := &models.User{ - Model: gorm.Model{ID: 1}, - Username: "loginuser", - Password: "correctpassword", // 明文密码,BeforeCreate 会哈希它 - } - // 调用 BeforeCreate 钩子来哈希密码 - _ = mockUser.BeforeCreate(nil) - m.On("FindByUsername", "loginuser").Return(mockUser, nil).Once() - }, - mockTokenServiceSetup: func(m *MockTokenService) {}, - expectedResponse: map[string]interface{}{ - "code": float64(controller.CodeUnauthorized), - "message": "用户名或密码不正确", - "data": nil, - }, - }, - { - name: "生成Token失败", - requestBody: user.LoginRequest{ - Username: "loginuser", - Password: "correctpassword", - }, - mockRepoSetup: func(m *MockUserRepository) { - mockUser := &models.User{ - Model: gorm.Model{ID: 1}, - Username: "loginuser", - Password: "correctpassword", // 明文密码,BeforeCreate 会哈希它 - } - // 调用 BeforeCreate 钩子来哈希密码 - _ = mockUser.BeforeCreate(nil) - m.On("FindByUsername", "loginuser").Return(mockUser, nil).Once() - }, - mockTokenServiceSetup: func(m *MockTokenService) { - m.On("GenerateToken", uint(1)).Return("", errors.New("jwt error")).Once() - }, - expectedResponse: map[string]interface{}{ - "code": float64(controller.CodeInternalError), - "message": "登录失败,无法生成认证信息", - "data": nil, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // 初始化 Gin 上下文和记录器 - w := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(w) - ctx.Request = httptest.NewRequest(http.MethodPost, "/login", nil) // URL 路径不重要,因为我们不测试路由 - - // 设置请求体 - jsonBody, _ := json.Marshal(tt.requestBody) - ctx.Request.Body = io.NopCloser(bytes.NewBuffer(jsonBody)) - ctx.Request.Header.Set("Content-Type", "application/json") - - // 创建 Mock - mockRepo := new(MockUserRepository) - mockTokenService := new(MockTokenService) - - // 设置 Mock 行为 - tt.mockRepoSetup(mockRepo) - tt.mockTokenServiceSetup(mockTokenService) - - // 创建控制器实例 - userController := user.NewController(mockRepo, silentLogger, mockTokenService) - - // 调用被测试的方法 - userController.Login(ctx) - - // 解析响应体 - var responseBody map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &responseBody) - assert.NoError(t, err) - - // 断言响应体中的 code 字段 - assert.Equal(t, tt.expectedResponse["code"], responseBody["code"]) - - // 断言响应内容 (除了 code 字段) - if tt.expectedResponse["code"] == float64(controller.CodeSuccess) { - // 确保 data 字段存在且是 map[string]interface{} 类型 - data, ok := responseBody["data"].(map[string]interface{}) - assert.True(t, ok, "响应体中的 data 字段应为 map[string]interface{}") - - // 验证 id 和 token 存在 - assert.NotNil(t, data["id"]) - assert.NotNil(t, data["token"]) - - // 移除 ID 和 Token 字段以便进行通用断言 - delete(responseBody["data"].(map[string]interface{}), "id") - delete(tt.expectedResponse["data"].(map[string]interface{}), "id") - delete(responseBody["data"].(map[string]interface{}), "token") - delete(tt.expectedResponse["data"].(map[string]interface{}), "token") - } - // 移除 code 字段以便进行通用断言 - delete(responseBody, "code") - delete(tt.expectedResponse, "code") - assert.Equal(t, tt.expectedResponse, responseBody) - - // 验证 Mock 期望是否都已满足 - mockRepo.AssertExpectations(t) - mockTokenService.AssertExpectations(t) - }) - } -} diff --git a/openspec/changes/refactor-migrate-gin-to-echo/design.md b/openspec/changes/refactor-migrate-gin-to-echo/design.md new file mode 100644 index 0000000..6a211df --- /dev/null +++ b/openspec/changes/refactor-migrate-gin-to-echo/design.md @@ -0,0 +1,78 @@ +## Context + +当前 API 服务基于 Gin 构建。本次任务的目标是将其完整迁移到 Echo 框架,同时保持功能和接口的完全向后兼容。这包括路由、请求处理、中间件、Swagger 文档和 pprof 分析工具。 + +## Goals / Non-Goals + +- **Goals**: + - 成功将 Web 框架从 Gin 迁移到 Echo v4。 + - 保持所有现有 API 端点的路径、方法和行为不变。 + - 确保所有自定义中间件(认证、审计日志)功能正常。 + - 确保 Swagger UI 可以在 `/swagger/index.html` 正常访问。 + - 确保 pprof 调试端点在 `/debug/pprof/*` 路径下正常工作。 +- **Non-Goals**: + - 增加任何新的 API 端点或功能。 + - 修改任何现有的 API 请求/响应模型。 + - 在本次变更中引入新的业务逻辑。 + +## Decisions + +以下是从 Gin 到 Echo 的关键组件映射决策: + +1. **框架实例**: + - **From**: `gin.SetMode(cfg.Mode)`, `engine := gin.New()`, `engine.Use(gin.Recovery())` + - **To**: `e := echo.New()`, `e.Debug = (cfg.Mode == "debug")`, `e.Use(middleware.Recover())` + - **Rationale**: `echo.New()` 提供了干净的实例。Echo 的 `Debug` 属性控制调试模式,可以根据配置设置。Echo 提供了内置的 `middleware.Recover()` 来替代 Gin 的 Recovery 中间件。 + - **Implementation**: + - 在 `internal/app/api/api.go` 中,将 `engine *gin.Engine` 替换为 `engine *echo.Echo`,并更新 `NewAPI` 方法中的初始化逻辑。 + - 在 `config.yml` 和 `config.example.yml` 中, 更新关于 `mode` 配置项的注释, 将 "Gin 运行模式" 修改为 "服务运行模式", 因为该配置项现在控制 Echo 的调试模式。 + +2. **上下文对象 (Context) 与处理器签名**: + - **From**: `func(c *gin.Context)` + - **To**: `func(c echo.Context) error` + - **Rationale**: 这是两个框架的核心区别。所有控制器处理函数签名都需要更新。常见方法映射如下: + - `ctx.ShouldBindJSON(&req)` -> `c.Bind(&req)` (Echo 的 `Bind` 更通用,能根据 `Content-Type` 自动选择解析器) + - `ctx.ShouldBindQuery(&req)` -> `c.Bind(&req)` + - `ctx.Param("id")` -> `c.Param("id")` (签名相同) + - `ctx.GetHeader("Authorization")` -> `c.Request().Header.Get("Authorization")` + - `ctx.Set("key", value)` -> `c.Set("key", value)` (签名相同) + - `ctx.Get("key")` -> `c.Get("key")` (签名相同) + - `ctx.ClientIP()` -> `c.RealIP()` + - `controller.SendResponse(ctx, ...)` -> `return controller.SendResponse(c, ...)` (控制器方法需要返回 `error`,辅助函数也需要修改以返回 `error`) + - `controller.SendErrorResponse(ctx, ...)` -> `return controller.SendErrorResponse(c, ...)` (同上) + - `ctx.AbortWithStatusJSON(...)` -> `return c.JSON(...)` (在中间件中,通过 `return c.JSON(...)` 来中断链并响应) + +3. **中间件 (Middleware)**: + - **From**: `func AuthMiddleware(...) gin.HandlerFunc { return func(c *gin.Context) { ... } }` + - **To**: `func AuthMiddleware(...) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { ...; return next(c) } } }` + - **Rationale**: Echo 的中间件是一个包装器模式。我们需要将现有的 `AuthMiddleware` 和 `AuditLogMiddleware` 逻辑迁移到这个新的结构中。中断请求链的方式从 `c.AbortWithStatusJSON()` 变为从处理函数中 `return c.JSON(...)`。 + +4. **Swagger 集成**: + - **From**: `github.com/swaggo/gin-swagger` + - **To**: `github.com/swaggo/echo-swagger` + - **Rationale**: 这是 `swaggo` 官方为 Echo 提供的适配库,可以无缝替换。 + - **Implementation**: 在 `router.go` 中使用 `e.GET("/swagger/*", echoSwagger.WrapHandler)`。 + +5. **Pprof 与其他 `net/http` 处理器集成**: + - **From**: `gin.WrapH` 和 `gin.WrapF` + - **To**: `echo.WrapHandler` 和 `echo.WrapFunc` + - **Rationale**: Echo 提供了类似的 `net/http` 处理器包装函数,可以轻松集成 pprof 和项目中的 `listenHandler`。 + - **Implementation**: 在 `router.go` 中替换所有 `gin.WrapH` 和 `gin.WrapF` 的调用。 + +6. **控制器辅助函数**: + - **Affected Files**: + - `internal/app/controller/response.go` + - `internal/app/controller/auth_utils.go` + - `internal/app/controller/management/controller_helpers.go` + - **Change**: + - 在 `response.go` 和 `auth_utils.go` 中, 所有接收 `*gin.Context` 的辅助函数 (如 `SendResponse`, `GetOperatorIDFromContext` 等) 签名都需要修改为接收 `echo.Context`。 + - 在 `controller_helpers.go` 中, `handle...` 系列的泛型辅助函数 (如 `handleAPIRequest`, `handleNoBodyAPIRequest` 等) 及其依赖的 `extractOperatorAndPrimaryID` 和 `mapAndSendError` 函数, 都需要将其中的 `*gin.Context` 参数和相关调用 (如 `ShouldBindJSON`) 替换为 `echo.Context` 的等效实现。 + - 所有这些辅助函数, 如果它们原本不返回 `error`, 现在需要修改为返回 `error`, 以便与 Echo 的处理器错误链兼容。例如, `SendResponse` 这类函数在调用 `c.JSON(...)` 后, 最终应 `return nil`。 + - **Rationale**: 这些辅助函数封装了请求处理、响应发送和错误处理的核心逻辑, 必须进行适配以兼容 Echo 的 `echo.Context` 上下文对象和 `return error` 的错误处理模式。 + +## Risks / Trade-offs + +- **Risk**: 迁移工作量大,可能遗漏某些 Gin 特有的功能或上下文用法,导致运行时错误。 +- **Mitigation**: 采用逐个文件、逐个控制器修改的方式,每修改完一部分就进行编译检查。在完成所有编码后,进行全面的手动 API 测试。 +- **Risk**: `AuditLogMiddleware` 中间件依赖 `bodyLogWriter` 捕获响应体,需要验证其与 Echo 的 `ResponseWriter` 是否兼容或需要寻找替代方案。 +- **Mitigation**: 在迁移中间件时,优先研究 Echo 官方推荐的 Body Dump 或类似中间件,如果不适用,再尝试适配 `bodyLogWriter`。 diff --git a/openspec/changes/refactor-migrate-gin-to-echo/proposal.md b/openspec/changes/refactor-migrate-gin-to-echo/proposal.md new file mode 100644 index 0000000..576b96c --- /dev/null +++ b/openspec/changes/refactor-migrate-gin-to-echo/proposal.md @@ -0,0 +1,26 @@ +## Why + +本项目当前使用 Gin 作为核心 Web 框架。Gin 的路由系统存在一些限制,例如无法优雅地支持类似 `/:id/action` 和 `/:other_id/other-action` 这种在同一层级使用不同动态参数的路由模式。为了解决此问题并利用更现代、灵活的路由和中间件系统,我们计划将框架迁移到 Echo (v4)。本次变更仅进行框架替换,暂不修改现有路由结构。 + +## What Changes + +- **核心框架替换**: 将 `github.com/gin-gonic/gin` 的所有引用替换为 `github.com/labstack/echo/v4`。 +- **API 路由重写**: 更新 `internal/app/api/router.go` 以使用 Echo 的路由注册方式。 +- **上下文对象适配**: 在所有 Controller 和 Middleware 中,将 `*gin.Context` 替换为 `echo.Context`,并调整相关方法调用。 +- **中间件迁移**: 将现有的 Gin 中间件 (`AuthMiddleware`, `AuditLogMiddleware`) 适配为 Echo 的中间件格式。 +- **Swagger 文档适配**: 将 `gin-swagger` 替换为 Echo 兼容的 `echo-swagger`,确保 API 文档能够正常生成和访问。 +- **Pprof 路由适配**: 确保性能分析工具 pprof 的路由在 Echo 框架下正常工作。 + +**BREAKING**: 这是一项纯粹的技术栈重构,**不应该**对外部 API 消费者产生任何破坏性影响。所有 API 端点、请求/响应格式将保持完全兼容。 + +## Impact + +- **Affected specs**: 无。此变更是技术实现层面的重构,不改变任何已定义的功能规约。 +- **Affected code**: + - `go.mod` / `go.sum`: 依赖项变更。 + - `config.yml` / `config.example.yml`: 更新 `mode` 配置项的注释。 + - `internal/app/api/api.go` + - `internal/app/api/router.go` + - `internal/app/middleware/auth.go` + - `internal/app/middleware/audit.go` + - `internal/app/controller/**/*.go`: 所有控制器及其辅助函数。 \ No newline at end of file diff --git a/openspec/changes/refactor-migrate-gin-to-echo/specs/http-server/spec.md b/openspec/changes/refactor-migrate-gin-to-echo/specs/http-server/spec.md new file mode 100644 index 0000000..c3fea16 --- /dev/null +++ b/openspec/changes/refactor-migrate-gin-to-echo/specs/http-server/spec.md @@ -0,0 +1,17 @@ +# HTTP Server Specification + +本文档概述了 HTTP 服务器的需求。 + +## MODIFIED Requirements + +### Requirement: API 服务器框架已更新 + +- **说明**: 底层 Web 框架从 Gin 迁移到 Echo。所有现有的 API 端点 **MUST** 保持功能齐全和向后兼容。 +- **理由**: 为了提高路由灵活性并使技术栈现代化。这是一次技术重构,不会改变任何外部 API 行为。 +- **影响**: 高。影响核心请求处理、路由和中间件。 +- **受影响的端点**: 全部。 + +#### Scenario: 所有现有的 API 端点保持功能齐全和向后兼容 +- **假如**: API 服务器在迁移到 Echo 后正在运行。 +- **当**: 客户端向任何现有的 API 端点(例如, `POST /api/v1/users/login`)发送请求。 +- **那么**: 服务器处理该请求并返回与使用 Gin 框架时完全相同的响应(状态码、头部和正文格式)。 diff --git a/openspec/changes/refactor-migrate-gin-to-echo/tasks.md b/openspec/changes/refactor-migrate-gin-to-echo/tasks.md new file mode 100644 index 0000000..4c7a7d6 --- /dev/null +++ b/openspec/changes/refactor-migrate-gin-to-echo/tasks.md @@ -0,0 +1,60 @@ +## 任务清单:Gin 到 Echo 迁移 + +- [ ] **1. 配置文件 (无代码依赖)** + - [ ] 修改 `config.yml` 中 `mode` 配置项的注释,将 "Gin 运行模式" 改为 "服务运行模式"。 + - [ ] 修改 `config.example.yml` 中 `mode` 配置项的注释,保持与 `config.yml` 一致。 + +- [ ] **2. 控制器辅助函数 (最基础的依赖)** + - [ ] **`internal/app/controller/response.go`** + - [ ] 将 `*gin.Context` 参数全部替换为 `echo.Context`。 + - [ ] 修改 `SendResponse` 和 `SendErrorResponse` 等函数,使其不再直接写入响应,而是返回 `error`,并在内部调用 `c.JSON(...)`。 + - [ ] **`internal/app/controller/auth_utils.go`** + - [ ] 将 `*gin.Context` 参数全部替换为 `echo.Context`。 + - [ ] 适配 `Get...FromContext` 系列函数,使用 `c.Get("key")` 提取数据。 + +- [ ] **3. 中间件 (`internal/app/middleware`)** + - [ ] **`auth.go`** + - [ ] 将 `import "github.com/gin-gonic/gin"` 替换为 `import "github.com/labstack/echo/v4"`。 + - [ ] 将中间件函数签名从 `func AuthMiddleware(...) gin.HandlerFunc` 更新为 `func AuthMiddleware(...) echo.MiddlewareFunc`。 + - [ ] 适配中间件内部逻辑,将 `func(c *gin.Context)` 改造为 `func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { ... } }` 的结构。 + - [ ] 将 `c.AbortWithStatusJSON(...)` 调用替换为 `return c.JSON(...)`。 + - [ ] 在逻辑正常通过的末尾,调用 `return next(c)`。 + +- [ ] **4. 控制器 (`internal/app/controller/...`)** + - [ ] **通用修改**:对所有控制器文件执行以下操作: + - [ ] 将 `import "github.com/gin-gonic/gin"` 替换为 `import "github.com/labstack/echo/v4"`。 + - [ ] 将所有处理函数签名从 `func(c *gin.Context)` 修改为 `func(c echo.Context) error`。 + - [ ] 将 `c.ShouldBindJSON(&req)` 或 `c.ShouldBindQuery(&req)` 替换为 `if err := c.Bind(&req); err != nil { ... }`。 + - [ ] 将 `c.Param("id")` 替换为 `c.Param("id")` (用法相同,检查返回值即可)。 + - [ ] 将 `controller.SendResponse(c, ...)` 和 `controller.SendErrorResponse(c, ...)` 调用修改为 `return controller.SendResponse(c, ...)` 和 `return controller.SendErrorResponse(c, ...)`。 + - [ ] **文件清单** (按依赖顺序建议): + - [ ] `internal/app/controller/management/controller_helpers.go` (注意此文件中的泛型辅助函数也需要适配) + - [ ] `internal/app/controller/device/device_controller.go` + - [ ] `internal/app/controller/management/pig_batch_controller.go` + - [ ] `internal/app/controller/plan/plan_controller.go` + - [ ] `internal/app/controller/user/user_controller.go` + +- [ ] **5. 核心 API 层 (`internal/app/api`)** + - [ ] **`router.go`** + - [ ] 将所有 `router.GET`, `router.POST` 等 Gin 路由注册方法替换为 Echo 的 `e.GET`, `e.POST` 等方法。 + - [ ] 将 Swagger 路由 `router.GET("/swagger/*", ginSwagger.WrapHandler(swaggerFiles.Handler))` 替换为 `e.GET("/swagger/*", echoSwagger.WrapHandler)`。 + - [ ] 将 pprof 路由的 `gin.WrapH` 和 `gin.WrapF` 调用替换为 `echo.WrapHandler` 和 `echo.WrapFunc`。 + - [ ] **`api.go`** + - [ ] 将 `engine *gin.Engine` 替换为 `engine *echo.Echo`。 + - [ ] 更新 `NewAPI` 函数: + - [ ] 将 `gin.SetMode(cfg.Mode)` 替换为 `e.Debug = (cfg.Mode == "debug")`。 + - [ ] 将 `gin.New()` 替换为 `echo.New()`。 + - [ ] 将 `engine.Use(gin.Recovery())` 替换为 `e.Use(middleware.Recover())`。 + +- [ ] **6. 依赖管理** + - [ ] 在 `go.mod` 中移除 `github.com/gin-gonic/gin`。 + - [ ] 在 `go.mod` 中移除 `github.com/swaggo/gin-swagger`。 + - [ ] 在 `go.mod` 中添加 `github.com/labstack/echo/v4`。 + - [ ] 在 `go.mod` 中添加 `github.com/swaggo/echo-swagger`。 + - [ ] 执行 `go mod tidy` 清理依赖项。 + +- [ ] **7. 验证** + - [ ] 运行 `go build ./...` 确保项目能够成功编译。 + - [ ] 启动服务,手动测试所有 API 端点,验证功能是否与迁移前一致。 + - [ ] 访问 `/swagger/index.html`,确认 Swagger UI 是否正常工作。 + - [ ] (可选) 访问 `/debug/pprof/`,确认 pprof 路由是否正常。