生成openspace任务列表
This commit is contained in:
		| @@ -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) | ||||
| 			}) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| @@ -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) | ||||
| 	}) | ||||
| } | ||||
| @@ -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) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										78
									
								
								openspec/changes/refactor-migrate-gin-to-echo/design.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								openspec/changes/refactor-migrate-gin-to-echo/design.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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`。 | ||||
							
								
								
									
										26
									
								
								openspec/changes/refactor-migrate-gin-to-echo/proposal.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								openspec/changes/refactor-migrate-gin-to-echo/proposal.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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`: 所有控制器及其辅助函数。 | ||||
| @@ -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 框架时完全相同的响应(状态码、头部和正文格式)。 | ||||
							
								
								
									
										60
									
								
								openspec/changes/refactor-migrate-gin-to-echo/tasks.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								openspec/changes/refactor-migrate-gin-to-echo/tasks.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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 路由是否正常。 | ||||
		Reference in New Issue
	
	Block a user