生成openspace任务列表

This commit is contained in:
2025-10-30 16:10:10 +08:00
parent 93f67812ae
commit 2c9b4777ae
7 changed files with 181 additions and 2018 deletions

View File

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

View File

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

View File

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