diff --git a/TODO-List b/TODO-List index 89dd658..ed33267 100644 --- a/TODO-List +++ b/TODO-List @@ -1,6 +1 @@ // TODO 列表 - -1. websocket不是安全的wss -2. 添加设备时应该激活一下设备状态采集 -3. 设备Model缺少硬件地址 -4. 如果同时有两条请求发给同一个设备, 会不会导致接收到错误的回复 diff --git a/internal/app/controller/device/device_controller_test.go b/internal/app/controller/device/device_controller_test.go new file mode 100644 index 0000000..47d3ced --- /dev/null +++ b/internal/app/controller/device/device_controller_test.go @@ -0,0 +1,754 @@ +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" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// MockDeviceRepository 是 DeviceRepository 接口的模拟实现 +type MockDeviceRepository struct { + mock.Mock +} + +// Create 模拟 DeviceRepository 的 Create 方法 +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) +} + +// getSilentLogger 创建一个不输出日志的 logs.Logger 实例,用于测试 +func getSilentLogger() *logs.Logger { + discardSyncer := zapcore.AddSync(io.Discard) + encoderConfig := zap.NewProductionEncoderConfig() + encoder := zapcore.NewConsoleEncoder(encoderConfig) + core := zapcore.NewCore(encoder, discardSyncer, zap.DebugLevel) + zapLogger := zap.New(core) + sugaredLogger := zapLogger.Sugar() + return &logs.Logger{SugaredLogger: sugaredLogger} +} + +// 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("Create", 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: http.StatusCreated, + 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("Create", 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: http.StatusCreated, + 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: http.StatusBadRequest, + 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("Create", mock.Anything).Return(errors.New("db error")).Once() + }, + expectedStatus: http.StatusOK, + expectedCode: http.StatusInternalServerError, + 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) { + // 期望 Create 方法被调用,并返回一个模拟的数据库错误 + // 这个错误模拟的是数据库层因为 Properties 字段的 JSON 格式无效而拒绝保存 + m.On("Create", 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: http.StatusInternalServerError, // 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, getSilentLogger()).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: http.StatusOK, + 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: http.StatusNotFound, + 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: http.StatusBadRequest, + 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: http.StatusInternalServerError, + 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, getSilentLogger()).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: http.StatusOK, + 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: http.StatusOK, + 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: http.StatusInternalServerError, + 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, getSilentLogger()).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: http.StatusOK, + 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: http.StatusBadRequest, + 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: http.StatusNotFound, + 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: http.StatusBadRequest, + 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: http.StatusInternalServerError, + 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: http.StatusInternalServerError, // 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: http.StatusOK, + 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, getSilentLogger()).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: http.StatusOK, + 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: http.StatusBadRequest, + 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: http.StatusInternalServerError, + 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: http.StatusInternalServerError, // 当前控制器逻辑会将 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, getSilentLogger()).DeleteDevice(ctx) + }) + }) + } +}