742 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			742 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| 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
 | ||
| }
 | ||
| 
 | ||
| // 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)
 | ||
| }
 | ||
| 
 | ||
| // 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:    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("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:    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("Create", 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) {
 | ||
| 				// 期望 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:     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)
 | ||
| 			})
 | ||
| 		})
 | ||
| 	}
 | ||
| }
 |