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) }) }) } }