生成openspace任务列表
This commit is contained in:
@@ -1,741 +0,0 @@
|
|||||||
package device_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
|
|
||||||
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/device"
|
|
||||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
|
|
||||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockDeviceRepository 是 DeviceRepository 接口的模拟实现
|
|
||||||
type MockDeviceRepository struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateTx 模拟 DeviceRepository 的 CreateTx 方法
|
|
||||||
func (m *MockDeviceRepository) Create(device *models.Device) error {
|
|
||||||
args := m.Called(device)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindByID 模拟 DeviceRepository 的 FindByID 方法
|
|
||||||
func (m *MockDeviceRepository) FindByID(id uint) (*models.Device, error) {
|
|
||||||
args := m.Called(id)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(*models.Device), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindByIDString 模拟 DeviceRepository 的 FindByIDString 方法
|
|
||||||
func (m *MockDeviceRepository) FindByIDString(id string) (*models.Device, error) {
|
|
||||||
args := m.Called(id)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(*models.Device), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAll 模拟 DeviceRepository 的 ListAll 方法
|
|
||||||
func (m *MockDeviceRepository) ListAll() ([]*models.Device, error) {
|
|
||||||
args := m.Called()
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).([]*models.Device), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListByParentID 模拟 DeviceRepository 的 ListByParentID 方法
|
|
||||||
func (m *MockDeviceRepository) ListByParentID(parentID *uint) ([]*models.Device, error) {
|
|
||||||
args := m.Called(parentID)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).([]*models.Device), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update 模拟 DeviceRepository 的 Update 方法
|
|
||||||
func (m *MockDeviceRepository) Update(device *models.Device) error {
|
|
||||||
args := m.Called(device)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete 模拟 DeviceRepository 的 Delete 方法
|
|
||||||
func (m *MockDeviceRepository) Delete(id uint) error {
|
|
||||||
args := m.Called(id)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// testCase 结构体定义了所有测试用例的通用参数
|
|
||||||
type testCase struct {
|
|
||||||
name string
|
|
||||||
httpMethod string // 新增字段:HTTP 方法
|
|
||||||
requestBody interface{}
|
|
||||||
paramID string // URL 中的 ID 参数
|
|
||||||
mockRepoSetup func(*MockDeviceRepository)
|
|
||||||
expectedStatus int // HTTP 状态码
|
|
||||||
expectedCode int // 业务状态码
|
|
||||||
expectedMessage string
|
|
||||||
expectedDataFunc func(interface{}) bool // 用于验证 data 字段的函数
|
|
||||||
}
|
|
||||||
|
|
||||||
// runTest 是一个辅助函数,用于执行单个测试用例
|
|
||||||
func runTest(t *testing.T, tc testCase, controllerMethod func(*gin.Context, *MockDeviceRepository)) {
|
|
||||||
// 初始化 Gin 上下文
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
ctx, _ := gin.CreateTestContext(w)
|
|
||||||
|
|
||||||
// 设置请求体和 HTTP 方法
|
|
||||||
if tc.requestBody != nil {
|
|
||||||
jsonBody, _ := json.Marshal(tc.requestBody)
|
|
||||||
ctx.Request = httptest.NewRequest(tc.httpMethod, "/", io.NopCloser(bytes.NewBuffer(jsonBody)))
|
|
||||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
|
||||||
} else {
|
|
||||||
// 对于没有请求体的请求 (GET, DELETE, 或没有 body 的 POST/PUT)
|
|
||||||
ctx.Request = httptest.NewRequest(tc.httpMethod, "/", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置 URL 参数
|
|
||||||
if tc.paramID != "" {
|
|
||||||
ctx.Params = append(ctx.Params, gin.Param{Key: "id", Value: tc.paramID})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建 Mock Repository
|
|
||||||
mockRepo := new(MockDeviceRepository)
|
|
||||||
// 设置 Mock 行为
|
|
||||||
tc.mockRepoSetup(mockRepo)
|
|
||||||
|
|
||||||
// 调用被测试的方法,并传入 mockRepo
|
|
||||||
controllerMethod(ctx, mockRepo)
|
|
||||||
|
|
||||||
// 解析响应体
|
|
||||||
var responseBody controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &responseBody)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// 断言 HTTP 状态码始终为 200 OK
|
|
||||||
assert.Equal(t, tc.expectedStatus, w.Code)
|
|
||||||
|
|
||||||
// 断言业务状态码和消息
|
|
||||||
assert.Equal(t, tc.expectedCode, responseBody.Code)
|
|
||||||
assert.Equal(t, tc.expectedMessage, responseBody.Message)
|
|
||||||
|
|
||||||
// 断言数据字段
|
|
||||||
if tc.expectedDataFunc != nil {
|
|
||||||
var data interface{}
|
|
||||||
// 只有当 responseBody.Data 不为 nil 且其底层类型为 []byte 时才尝试 Unmarshal
|
|
||||||
if responseBody.Data != nil {
|
|
||||||
if byteData, ok := responseBody.Data.([]byte); ok {
|
|
||||||
err = json.Unmarshal(byteData, &data)
|
|
||||||
assert.NoError(t, err, "无法解析响应数据") // 增加对 Unmarshal 错误的断言
|
|
||||||
} else {
|
|
||||||
// 如果 Data 不为 nil 但也不是 []byte,这通常不应该发生
|
|
||||||
// 但为了健壮性,直接将原始 interface{} 赋值给 data
|
|
||||||
data = responseBody.Data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.True(t, tc.expectedDataFunc(data), "数据字段验证失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证 Mock 期望是否都已满足
|
|
||||||
mockRepo.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateDevice(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
tests := []testCase{
|
|
||||||
{
|
|
||||||
name: "成功创建区域主控",
|
|
||||||
httpMethod: http.MethodPost,
|
|
||||||
requestBody: device.CreateDeviceRequest{
|
|
||||||
Name: "主控A",
|
|
||||||
Type: models.DeviceTypeAreaController,
|
|
||||||
Location: "猪舍1",
|
|
||||||
Properties: controller.Properties(`{"lora_address":"0x1234"}`),
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("CreateTx", mock.MatchedBy(func(dev *models.Device) bool {
|
|
||||||
// 检查 Name 字段
|
|
||||||
nameMatch := dev.Name == "主控A"
|
|
||||||
// 检查 Type 字段
|
|
||||||
typeMatch := dev.Type == models.DeviceTypeAreaController
|
|
||||||
// 检查 Location 字段
|
|
||||||
locationMatch := dev.Location == "猪舍1"
|
|
||||||
// 检查 Properties 字段的字节内容
|
|
||||||
expectedProperties := controller.Properties(`{"lora_address":"0x1234"}`)
|
|
||||||
propertiesMatch := bytes.Equal(dev.Properties, expectedProperties)
|
|
||||||
|
|
||||||
return nameMatch && typeMatch && locationMatch && propertiesMatch
|
|
||||||
})).Return(nil).Run(func(args mock.Arguments) {
|
|
||||||
// 模拟 GORM 自动填充 ID
|
|
||||||
arg := args.Get(0).(*models.Device)
|
|
||||||
arg.ID = 1
|
|
||||||
arg.CreatedAt = time.Now()
|
|
||||||
arg.UpdatedAt = time.Now()
|
|
||||||
}).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeCreated,
|
|
||||||
expectedMessage: "设备创建成功",
|
|
||||||
expectedDataFunc: func(data interface{}) bool {
|
|
||||||
dataMap, ok := data.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return dataMap["id"] != nil &&
|
|
||||||
dataMap["name"] == "主控A" &&
|
|
||||||
dataMap["type"] == string(models.DeviceTypeAreaController) &&
|
|
||||||
dataMap["properties"] != nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "成功创建普通设备",
|
|
||||||
httpMethod: http.MethodPost,
|
|
||||||
requestBody: device.CreateDeviceRequest{
|
|
||||||
Name: "温度传感器",
|
|
||||||
Type: models.DeviceTypeDevice,
|
|
||||||
SubType: models.SubTypeSensorTemp,
|
|
||||||
ParentID: func() *uint { id := uint(1); return &id }(),
|
|
||||||
Location: "猪舍1-A区",
|
|
||||||
Properties: controller.Properties(`{"bus_id":1,"bus_address":10}`),
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("CreateTx", mock.Anything).Return(nil).Run(func(args mock.Arguments) {
|
|
||||||
arg := args.Get(0).(*models.Device)
|
|
||||||
arg.ID = 2
|
|
||||||
arg.CreatedAt = time.Now()
|
|
||||||
arg.UpdatedAt = time.Now()
|
|
||||||
}).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeCreated,
|
|
||||||
expectedMessage: "设备创建成功",
|
|
||||||
expectedDataFunc: func(data interface{}) bool {
|
|
||||||
dataMap, ok := data.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return dataMap["id"] != nil &&
|
|
||||||
dataMap["name"] == "温度传感器" &&
|
|
||||||
dataMap["type"] == string(models.DeviceTypeDevice) &&
|
|
||||||
dataMap["sub_type"] == string(models.SubTypeSensorTemp) &&
|
|
||||||
dataMap["parent_id"] != nil &&
|
|
||||||
dataMap["properties"] != nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "请求参数绑定失败",
|
|
||||||
httpMethod: http.MethodPost,
|
|
||||||
requestBody: device.CreateDeviceRequest{
|
|
||||||
Name: "", // 缺少必填字段 Name
|
|
||||||
Type: models.DeviceTypeAreaController,
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeBadRequest,
|
|
||||||
expectedMessage: "Key: 'CreateDeviceRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "数据库创建失败",
|
|
||||||
httpMethod: http.MethodPost,
|
|
||||||
requestBody: device.CreateDeviceRequest{
|
|
||||||
Name: "失败设备",
|
|
||||||
Type: models.DeviceTypeDevice,
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("CreateTx", mock.Anything).Return(errors.New("db error")).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeInternalError,
|
|
||||||
expectedMessage: "创建设备失败",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
// 新增:Properties字段JSON格式无效
|
|
||||||
{
|
|
||||||
name: "Properties字段JSON格式无效",
|
|
||||||
httpMethod: http.MethodPost,
|
|
||||||
requestBody: device.CreateDeviceRequest{
|
|
||||||
Name: "无效JSON设备",
|
|
||||||
Type: models.DeviceTypeDevice,
|
|
||||||
Properties: controller.Properties(`{invalid json}`),
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
// 期望 CreateTx 方法被调用,并返回一个模拟的数据库错误
|
|
||||||
// 这个错误模拟的是数据库层因为 Properties 字段的 JSON 格式无效而拒绝保存
|
|
||||||
m.On("CreateTx", mock.Anything).Return(errors.New("database error: invalid json format")).Run(func(args mock.Arguments) {
|
|
||||||
dev := args.Get(0).(*models.Device)
|
|
||||||
assert.Equal(t, "无效JSON设备", dev.Name)
|
|
||||||
assert.Equal(t, models.DeviceTypeDevice, dev.Type)
|
|
||||||
expectedProperties := controller.Properties(`{invalid json}`)
|
|
||||||
assert.True(t, bytes.Equal(dev.Properties, expectedProperties), "Properties should match")
|
|
||||||
}).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK, // HTTP status is 200 OK for business errors
|
|
||||||
expectedCode: controller.CodeInternalError, // Business code for internal server error
|
|
||||||
expectedMessage: "创建设备失败", // The message returned by the controller
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) {
|
|
||||||
device.NewController(repo, logs.NewSilentLogger()).CreateDevice(ctx)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetDevice(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
tests := []testCase{
|
|
||||||
{
|
|
||||||
name: "成功获取设备",
|
|
||||||
httpMethod: http.MethodGet,
|
|
||||||
requestBody: nil,
|
|
||||||
paramID: "1",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("FindByIDString", "1").Return(&models.Device{
|
|
||||||
Model: gorm.Model{
|
|
||||||
ID: 1,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
},
|
|
||||||
Name: "测试设备",
|
|
||||||
Type: models.DeviceTypeAreaController,
|
|
||||||
Location: "测试地点",
|
|
||||||
Properties: datatypes.JSON(`{"key":"value"}`),
|
|
||||||
}, nil).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeSuccess,
|
|
||||||
expectedMessage: "获取设备信息成功",
|
|
||||||
expectedDataFunc: func(data interface{}) bool {
|
|
||||||
dataMap, ok := data.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return dataMap["id"] == float64(1) &&
|
|
||||||
dataMap["name"] == "测试设备" &&
|
|
||||||
dataMap["properties"] != nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "设备未找到",
|
|
||||||
httpMethod: http.MethodGet,
|
|
||||||
requestBody: nil,
|
|
||||||
paramID: "999",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("FindByIDString", "999").Return(nil, gorm.ErrRecordNotFound).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeNotFound,
|
|
||||||
expectedMessage: "设备未找到",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ID格式无效",
|
|
||||||
httpMethod: http.MethodGet,
|
|
||||||
requestBody: nil,
|
|
||||||
paramID: "abc",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("FindByIDString", "abc").Return(nil, errors.New("无效的设备ID格式")).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeBadRequest,
|
|
||||||
expectedMessage: "无效的设备ID格式",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "数据库查询失败",
|
|
||||||
httpMethod: http.MethodGet,
|
|
||||||
requestBody: nil,
|
|
||||||
paramID: "1",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("FindByIDString", "1").Return(nil, errors.New("db error")).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeInternalError,
|
|
||||||
expectedMessage: "获取设备信息失败",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) {
|
|
||||||
device.NewController(repo, logs.NewSilentLogger()).GetDevice(ctx)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDevices(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
tests := []testCase{
|
|
||||||
{
|
|
||||||
name: "成功获取空列表",
|
|
||||||
httpMethod: http.MethodGet,
|
|
||||||
requestBody: nil,
|
|
||||||
paramID: "",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("ListAll").Return([]*models.Device{}, nil).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeSuccess,
|
|
||||||
expectedMessage: "获取设备列表成功",
|
|
||||||
expectedDataFunc: func(data interface{}) bool {
|
|
||||||
s, ok := data.([]interface{})
|
|
||||||
return ok && len(s) == 0
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "成功获取包含设备的列表",
|
|
||||||
httpMethod: http.MethodGet,
|
|
||||||
requestBody: nil,
|
|
||||||
paramID: "",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("ListAll").Return([]*models.Device{
|
|
||||||
{
|
|
||||||
Model: gorm.Model{
|
|
||||||
ID: 1,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
},
|
|
||||||
Name: "设备1",
|
|
||||||
Type: models.DeviceTypeAreaController,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Model: gorm.Model{
|
|
||||||
ID: 2,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
},
|
|
||||||
Name: "设备2",
|
|
||||||
Type: models.DeviceTypeDevice,
|
|
||||||
SubType: models.SubTypeFan,
|
|
||||||
ParentID: func() *uint { id := uint(1); return &id }(),
|
|
||||||
},
|
|
||||||
}, nil).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeSuccess,
|
|
||||||
expectedMessage: "获取设备列表成功",
|
|
||||||
expectedDataFunc: func(data interface{}) bool {
|
|
||||||
dataList, ok := data.([]interface{})
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// 检查长度
|
|
||||||
if len(dataList) != 2 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// 检查第一个设备
|
|
||||||
item1, ok1 := dataList[0].(map[string]interface{})
|
|
||||||
if !ok1 || item1["id"] != float64(1) || item1["name"] != "设备1" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// 检查第二个设备
|
|
||||||
item2, ok2 := dataList[1].(map[string]interface{})
|
|
||||||
if !ok2 || item2["id"] != float64(2) || item2["name"] != "设备2" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "数据库查询失败",
|
|
||||||
httpMethod: http.MethodGet,
|
|
||||||
requestBody: nil,
|
|
||||||
paramID: "",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("ListAll").Return(nil, errors.New("db error")).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeInternalError,
|
|
||||||
expectedMessage: "获取设备列表失败",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) {
|
|
||||||
device.NewController(repo, logs.NewSilentLogger()).ListDevices(ctx)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateDevice(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
tests := []testCase{
|
|
||||||
{
|
|
||||||
name: "成功更新设备",
|
|
||||||
httpMethod: http.MethodPut,
|
|
||||||
requestBody: device.UpdateDeviceRequest{
|
|
||||||
Name: "更新后的主控",
|
|
||||||
Type: models.DeviceTypeAreaController,
|
|
||||||
Location: "新地点",
|
|
||||||
Properties: controller.Properties(`{"lora_address":"0x5678"}`),
|
|
||||||
},
|
|
||||||
paramID: "1",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
// 模拟 FindByIDString 找到设备
|
|
||||||
m.On("FindByIDString", "1").Return(&models.Device{
|
|
||||||
Model: gorm.Model{
|
|
||||||
ID: 1,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
},
|
|
||||||
Name: "旧主控",
|
|
||||||
Type: models.DeviceTypeAreaController,
|
|
||||||
Location: "旧地点",
|
|
||||||
Properties: datatypes.JSON(`{"lora_address":"0x1234"}`),
|
|
||||||
}, nil).Once()
|
|
||||||
// 模拟 Update 成功
|
|
||||||
m.On("Update", mock.AnythingOfType("*models.Device")).Return(nil).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeSuccess,
|
|
||||||
expectedMessage: "设备更新成功",
|
|
||||||
expectedDataFunc: func(data interface{}) bool {
|
|
||||||
dataMap, ok := data.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return dataMap["id"] == float64(1) &&
|
|
||||||
dataMap["name"] == "更新后的主控" &&
|
|
||||||
dataMap["location"] == "新地点" &&
|
|
||||||
dataMap["properties"] != nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "请求参数绑定失败",
|
|
||||||
httpMethod: http.MethodPut,
|
|
||||||
requestBody: device.UpdateDeviceRequest{
|
|
||||||
Name: "", // 缺少必填字段 Name
|
|
||||||
Type: models.DeviceTypeAreaController,
|
|
||||||
},
|
|
||||||
paramID: "1",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
// 模拟 FindByIDString 找到设备,以便进入参数绑定阶段
|
|
||||||
m.On("FindByIDString", "1").Return(&models.Device{Model: gorm.Model{ID: 1}}, nil).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeBadRequest,
|
|
||||||
expectedMessage: "Key: 'UpdateDeviceRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "设备未找到",
|
|
||||||
httpMethod: http.MethodPut,
|
|
||||||
requestBody: device.UpdateDeviceRequest{
|
|
||||||
Name: "任意名称", Type: models.DeviceTypeAreaController,
|
|
||||||
},
|
|
||||||
paramID: "999",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("FindByIDString", "999").Return(nil, gorm.ErrRecordNotFound).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeNotFound,
|
|
||||||
expectedMessage: "设备未找到",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ID格式无效",
|
|
||||||
httpMethod: http.MethodPut,
|
|
||||||
requestBody: device.UpdateDeviceRequest{
|
|
||||||
Name: "任意名称", Type: models.DeviceTypeAreaController,
|
|
||||||
},
|
|
||||||
paramID: "abc",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("FindByIDString", "abc").Return(nil, errors.New("无效的设备ID格式")).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeBadRequest,
|
|
||||||
expectedMessage: "无效的设备ID格式",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "数据库更新失败",
|
|
||||||
httpMethod: http.MethodPut,
|
|
||||||
requestBody: device.UpdateDeviceRequest{
|
|
||||||
Name: "更新失败设备", Type: models.DeviceTypeAreaController,
|
|
||||||
},
|
|
||||||
paramID: "1",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("FindByIDString", "1").Return(&models.Device{Model: gorm.Model{ID: 1}}, nil).Once()
|
|
||||||
m.On("Update", mock.AnythingOfType("*models.Device")).Return(errors.New("db error")).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeInternalError,
|
|
||||||
expectedMessage: "更新设备失败",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
// 新增:Properties字段JSON格式无效
|
|
||||||
{
|
|
||||||
name: "Properties字段JSON格式无效",
|
|
||||||
httpMethod: http.MethodPut,
|
|
||||||
requestBody: device.UpdateDeviceRequest{
|
|
||||||
Name: "无效JSON设备",
|
|
||||||
Type: models.DeviceTypeDevice,
|
|
||||||
Properties: controller.Properties(`{invalid json}`),
|
|
||||||
},
|
|
||||||
paramID: "1",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
// 模拟 FindByIDString 找到设备,以便进入参数绑定阶段
|
|
||||||
m.On("FindByIDString", "1").Return(&models.Device{Model: gorm.Model{ID: 1}}, nil).Once()
|
|
||||||
// 期望 Update 方法被调用,并返回一个模拟的数据库错误
|
|
||||||
m.On("Update", mock.Anything).Return(errors.New("database error: invalid json format")).Run(func(args mock.Arguments) {
|
|
||||||
dev := args.Get(0).(*models.Device)
|
|
||||||
assert.Equal(t, "无效JSON设备", dev.Name)
|
|
||||||
assert.Equal(t, models.DeviceTypeDevice, dev.Type)
|
|
||||||
expectedProperties := controller.Properties(`{invalid json}`)
|
|
||||||
assert.True(t, bytes.Equal(dev.Properties, expectedProperties), "Properties should match")
|
|
||||||
}).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeInternalError, // Expected to be internal server error due to DB error
|
|
||||||
expectedMessage: "更新设备失败", // The message returned by the controller
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
// 新增:成功更新设备的ParentID
|
|
||||||
{
|
|
||||||
name: "成功更新设备的ParentID",
|
|
||||||
httpMethod: http.MethodPut,
|
|
||||||
requestBody: device.UpdateDeviceRequest{
|
|
||||||
Name: "更新ParentID设备",
|
|
||||||
Type: models.DeviceTypeDevice,
|
|
||||||
ParentID: func() *uint { id := uint(10); return &id }(),
|
|
||||||
Location: "新地点",
|
|
||||||
Properties: controller.Properties(`{"key":"value"}`),
|
|
||||||
},
|
|
||||||
paramID: "1",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
// 模拟 FindByIDString 找到设备
|
|
||||||
m.On("FindByIDString", "1").Return(&models.Device{
|
|
||||||
Model: gorm.Model{
|
|
||||||
ID: 1,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
},
|
|
||||||
Name: "旧设备",
|
|
||||||
Type: models.DeviceTypeDevice,
|
|
||||||
ParentID: func() *uint { id := uint(1); return &id }(),
|
|
||||||
Location: "旧地点",
|
|
||||||
Properties: datatypes.JSON(`{"old_key":"old_value"}`),
|
|
||||||
}, nil).Once()
|
|
||||||
// 模拟 Update 成功,并验证 ParentID 被更新
|
|
||||||
m.On("Update", mock.MatchedBy(func(dev *models.Device) bool {
|
|
||||||
return dev.ID == 1 && *dev.ParentID == 10
|
|
||||||
})).Return(nil).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeSuccess,
|
|
||||||
expectedMessage: "设备更新成功",
|
|
||||||
expectedDataFunc: func(data interface{}) bool {
|
|
||||||
dataMap, ok := data.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return dataMap["id"] == float64(1) &&
|
|
||||||
dataMap["parent_id"] == float64(10) &&
|
|
||||||
dataMap["properties"] != nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) {
|
|
||||||
device.NewController(repo, logs.NewSilentLogger()).UpdateDevice(ctx)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteDevice(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
tests := []testCase{
|
|
||||||
{
|
|
||||||
name: "成功删除设备",
|
|
||||||
httpMethod: http.MethodDelete,
|
|
||||||
requestBody: nil,
|
|
||||||
paramID: "1",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("Delete", uint(1)).Return(nil).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeSuccess,
|
|
||||||
expectedMessage: "设备删除成功",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ID格式无效",
|
|
||||||
httpMethod: http.MethodDelete,
|
|
||||||
requestBody: nil,
|
|
||||||
paramID: "abc",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeBadRequest,
|
|
||||||
expectedMessage: "无效的设备ID格式",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "数据库删除失败",
|
|
||||||
httpMethod: http.MethodDelete,
|
|
||||||
requestBody: nil,
|
|
||||||
paramID: "1",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("Delete", uint(1)).Return(errors.New("db error")).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeInternalError,
|
|
||||||
expectedMessage: "删除设备失败",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
// 新增:删除设备未找到
|
|
||||||
{
|
|
||||||
name: "删除设备未找到",
|
|
||||||
httpMethod: http.MethodDelete,
|
|
||||||
requestBody: nil,
|
|
||||||
paramID: "999",
|
|
||||||
mockRepoSetup: func(m *MockDeviceRepository) {
|
|
||||||
m.On("Delete", uint(999)).Return(gorm.ErrRecordNotFound).Once()
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedCode: controller.CodeInternalError, // 当前控制器逻辑会将 ErrRecordNotFound 视为内部错误
|
|
||||||
expectedMessage: "删除设备失败",
|
|
||||||
expectedDataFunc: func(data interface{}) bool { return data == nil },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) {
|
|
||||||
device.NewController(repo, logs.NewSilentLogger()).DeleteDevice(ctx)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,827 +0,0 @@
|
|||||||
package plan
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
|
|
||||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
|
|
||||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
|
|
||||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockPlanRepository 是 repository.PlanRepository 的一个模拟实现,用于测试
|
|
||||||
type MockPlanRepository struct {
|
|
||||||
// CreatePlanFunc 模拟 CreatePlan 方法的行为
|
|
||||||
CreatePlanFunc func(plan *models.Plan) error
|
|
||||||
// GetPlanByIDFunc 模拟 GetPlanByID 方法的行为
|
|
||||||
GetPlanByIDFunc func(id uint) (*models.Plan, error)
|
|
||||||
// GetBasicPlanByIDFunc 模拟 GetBasicPlanByID 方法的行为
|
|
||||||
GetBasicPlanByIDFunc func(id uint) (*models.Plan, error)
|
|
||||||
// ListBasicPlansFunc 模拟 ListBasicPlans 方法的行为
|
|
||||||
ListBasicPlansFunc func() ([]models.Plan, error)
|
|
||||||
// UpdatePlanFunc 模拟 UpdatePlan 方法的行为
|
|
||||||
UpdatePlanFunc func(plan *models.Plan) error
|
|
||||||
// DeletePlanFunc 模拟 DeletePlan 方法的行为
|
|
||||||
DeletePlanFunc func(id uint) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListBasicPlans 实现了 MockPlanRepository 接口的 ListBasicPlans 方法
|
|
||||||
func (m *MockPlanRepository) ListBasicPlans() ([]models.Plan, error) {
|
|
||||||
return m.ListBasicPlansFunc()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBasicPlanByID 实现了 MockPlanRepository 接口的 GetBasicPlanByID 方法
|
|
||||||
func (m *MockPlanRepository) GetBasicPlanByID(id uint) (*models.Plan, error) {
|
|
||||||
return m.GetBasicPlanByIDFunc(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPlanByID 实现了 MockPlanRepository 接口的 GetPlanByID 方法
|
|
||||||
func (m *MockPlanRepository) GetPlanByID(id uint) (*models.Plan, error) {
|
|
||||||
return m.GetPlanByIDFunc(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreatePlan 实现了 MockPlanRepository 接口的 CreatePlan 方法
|
|
||||||
func (m *MockPlanRepository) CreatePlan(plan *models.Plan) error {
|
|
||||||
return m.CreatePlanFunc(plan)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdatePlan 实现了 MockPlanRepository 接口的 UpdatePlan 方法
|
|
||||||
func (m *MockPlanRepository) UpdatePlan(plan *models.Plan) error {
|
|
||||||
return m.UpdatePlanFunc(plan)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeletePlan 实现了 MockPlanRepository 接口的 DeletePlan 方法
|
|
||||||
func (m *MockPlanRepository) DeletePlan(id uint) error {
|
|
||||||
return m.DeletePlanFunc(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupTestRouter 创建一个用于测试的 gin 引擎和控制器实例
|
|
||||||
func setupTestRouter(repo repository.PlanRepository) *gin.Engine {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
router := gin.Default()
|
|
||||||
logger := logs.NewSilentLogger()
|
|
||||||
planController := NewController(logger, repo)
|
|
||||||
router.POST("/plans", planController.CreatePlan)
|
|
||||||
router.GET("/plans/:id", planController.GetPlan)
|
|
||||||
router.GET("/plans", planController.ListPlans)
|
|
||||||
router.PUT("/plans/:id", planController.UpdatePlan)
|
|
||||||
router.DELETE("/plans/:id", planController.DeletePlan)
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestController_CreatePlan 测试 CreatePlan 方法
|
|
||||||
func TestController_CreatePlan(t *testing.T) {
|
|
||||||
t.Run("成功-创建包含任务的计划", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
// 模拟仓库行为:CreatePlan 成功时,为计划和任务分配ID
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
CreatePlanFunc: func(plan *models.Plan) error {
|
|
||||||
plan.ID = 1
|
|
||||||
for i := range plan.Tasks {
|
|
||||||
plan.Tasks[i].ID = uint(i + 1)
|
|
||||||
plan.Tasks[i].PlanID = plan.ID
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
// 设置 Gin 路由器,并注入模拟仓库
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
|
|
||||||
// 准备请求体
|
|
||||||
reqBody := CreatePlanRequest{
|
|
||||||
Name: "Test Plan with Tasks",
|
|
||||||
ExecutionType: models.PlanExecutionTypeManual,
|
|
||||||
ContentType: models.PlanContentTypeTasks,
|
|
||||||
Tasks: []TaskRequest{
|
|
||||||
{Name: "Task 1", ExecutionOrder: 1, Type: models.TaskTypeWaiting},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bodyBytes, _ := json.Marshal(reqBody)
|
|
||||||
|
|
||||||
// 创建 HTTP 请求
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, "/plans", bytes.NewBuffer(bodyBytes))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
// 发送 HTTP 请求到路由器
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
// 验证 HTTP 状态码
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
// 解析响应体
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// 验证业务响应码和消息
|
|
||||||
assert.Equal(t, controller.CodeCreated, resp.Code)
|
|
||||||
assert.Equal(t, "计划创建成功", resp.Message)
|
|
||||||
|
|
||||||
// 验证返回数据中的计划ID
|
|
||||||
dataMap, ok := resp.Data.(map[string]interface{})
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, float64(1), dataMap["id"])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestController_GetPlan 是为 GetPlan 方法新增的单元测试函数
|
|
||||||
func TestController_GetPlan(t *testing.T) {
|
|
||||||
t.Run("成功-获取计划详情", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
// 模拟仓库行为:GetPlanByID 成功时返回一个计划
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
GetPlanByIDFunc: func(id uint) (*models.Plan, error) {
|
|
||||||
assert.Equal(t, uint(1), id)
|
|
||||||
return &models.Plan{
|
|
||||||
Model: gorm.Model{ID: 1},
|
|
||||||
Name: "Test Plan",
|
|
||||||
ContentType: models.PlanContentTypeTasks,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
// 设置 Gin 路由器
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
// 创建 HTTP 请求
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/plans/1", nil)
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeSuccess, resp.Code)
|
|
||||||
dataMap, ok := resp.Data.(map[string]interface{})
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, float64(1), dataMap["id"])
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("成功-获取内容为空的计划详情", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
// 模拟仓库行为:GetPlanByID 成功时返回一个任务列表为空的计划
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
GetPlanByIDFunc: func(id uint) (*models.Plan, error) {
|
|
||||||
assert.Equal(t, uint(3), id)
|
|
||||||
return &models.Plan{
|
|
||||||
Model: gorm.Model{ID: 3},
|
|
||||||
Name: "Empty Plan",
|
|
||||||
ContentType: models.PlanContentTypeTasks,
|
|
||||||
Tasks: []models.Task{}, // 任务列表为空
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/plans/3", nil)
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeSuccess, resp.Code)
|
|
||||||
|
|
||||||
dataMap, ok := resp.Data.(map[string]interface{})
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, float64(3), dataMap["id"])
|
|
||||||
assert.Equal(t, "Empty Plan", dataMap["name"])
|
|
||||||
|
|
||||||
// 关键断言:因为 omitempty 标签,当 tasks 列表为空时,该字段不应该出现在JSON中
|
|
||||||
_, ok = dataMap["tasks"]
|
|
||||||
assert.False(t, ok, "当任务列表为空时,'tasks' 字段因为 omitempty 标签,不应该出现在JSON响应中")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("失败-计划不存在", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
// 模拟仓库行为:GetPlanByID 返回记录未找到错误
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
GetPlanByIDFunc: func(id uint) (*models.Plan, error) {
|
|
||||||
return nil, gorm.ErrRecordNotFound
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/plans/999", nil)
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeNotFound, resp.Code)
|
|
||||||
assert.Equal(t, "计划不存在", resp.Message)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("失败-无效的ID格式", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
// 模拟仓库为空,因为预期不会调用仓库方法
|
|
||||||
mockRepo := &MockPlanRepository{}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
// 创建带有无效ID格式的 HTTP 请求
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/plans/abc", nil)
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeBadRequest, resp.Code)
|
|
||||||
assert.Equal(t, "无效的计划ID格式", resp.Message)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("失败-仓库层内部错误", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
internalErr := errors.New("database connection lost")
|
|
||||||
// 模拟仓库行为:GetPlanByID 返回内部错误
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
GetPlanByIDFunc: func(id uint) (*models.Plan, error) {
|
|
||||||
return nil, internalErr
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/plans/1", nil)
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeInternalError, resp.Code)
|
|
||||||
assert.Equal(t, "获取计划详情时发生内部错误", resp.Message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestController_ListPlans 测试 ListPlans 方法
|
|
||||||
func TestController_ListPlans(t *testing.T) {
|
|
||||||
t.Run("成功-获取计划列表", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
// 模拟返回的计划列表
|
|
||||||
mockPlans := []models.Plan{
|
|
||||||
{Model: gorm.Model{ID: 1}, Name: "Plan 1", ContentType: models.PlanContentTypeTasks},
|
|
||||||
{Model: gorm.Model{ID: 2}, Name: "Plan 2", ContentType: models.PlanContentTypeTasks},
|
|
||||||
}
|
|
||||||
// 模拟仓库行为:ListBasicPlans 成功时返回计划列表
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
ListBasicPlansFunc: func() ([]models.Plan, error) {
|
|
||||||
return mockPlans, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/plans", nil)
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeSuccess, resp.Code)
|
|
||||||
assert.Equal(t, "获取计划列表成功", resp.Message)
|
|
||||||
|
|
||||||
dataBytes, err := json.Marshal(resp.Data)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
var listResp ListPlansResponse
|
|
||||||
err = json.Unmarshal(dataBytes, &listResp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, 2, listResp.Total)
|
|
||||||
assert.Len(t, listResp.Plans, 2)
|
|
||||||
assert.Equal(t, uint(1), listResp.Plans[0].ID)
|
|
||||||
assert.Equal(t, "Plan 1", listResp.Plans[0].Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("成功-返回空列表", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
// 模拟仓库行为:ListBasicPlans 返回空列表
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
ListBasicPlansFunc: func() ([]models.Plan, error) {
|
|
||||||
return []models.Plan{}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/plans", nil)
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeSuccess, resp.Code)
|
|
||||||
|
|
||||||
dataBytes, err := json.Marshal(resp.Data)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
var listResp ListPlansResponse
|
|
||||||
err = json.Unmarshal(dataBytes, &listResp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, 0, listResp.Total)
|
|
||||||
assert.Len(t, listResp.Plans, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("失败-仓库层返回错误", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
dbErr := errors.New("db error")
|
|
||||||
// 模拟仓库行为:ListBasicPlans 返回数据库错误
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
ListBasicPlansFunc: func() ([]models.Plan, error) {
|
|
||||||
return nil, dbErr
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/plans", nil)
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeInternalError, resp.Code)
|
|
||||||
assert.Equal(t, "获取计划列表时发生内部错误", resp.Message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestController_UpdatePlan 是 UpdatePlan 的测试函数
|
|
||||||
func TestController_UpdatePlan(t *testing.T) {
|
|
||||||
t.Run("成功-更新计划", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
planID := uint(1)
|
|
||||||
updatedName := "Updated Plan Name"
|
|
||||||
// 模拟一个已存在的计划
|
|
||||||
mockPlan := &models.Plan{
|
|
||||||
Model: gorm.Model{ID: planID},
|
|
||||||
Name: "Original Plan",
|
|
||||||
Description: "Original Description",
|
|
||||||
ContentType: models.PlanContentTypeTasks,
|
|
||||||
}
|
|
||||||
// 配置模拟仓库的行为
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
// 模拟 GetBasicPlanByID 成功返回现有计划
|
|
||||||
GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) {
|
|
||||||
assert.Equal(t, planID, id)
|
|
||||||
return mockPlan, nil
|
|
||||||
},
|
|
||||||
// 模拟 UpdatePlan 成功更新计划,并更新 mockPlan 的名称
|
|
||||||
UpdatePlanFunc: func(plan *models.Plan) error {
|
|
||||||
assert.Equal(t, planID, plan.ID)
|
|
||||||
assert.Equal(t, updatedName, plan.Name)
|
|
||||||
mockPlan.Name = plan.Name // 模拟更新操作
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
// 模拟 GetPlanByID 返回更新后的计划
|
|
||||||
GetPlanByIDFunc: func(id uint) (*models.Plan, error) {
|
|
||||||
assert.Equal(t, planID, id)
|
|
||||||
return mockPlan, nil // 返回已更新的 mockPlan
|
|
||||||
},
|
|
||||||
}
|
|
||||||
// 设置 Gin 路由器,并注入模拟仓库
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
|
|
||||||
// 准备更新请求体
|
|
||||||
reqBody := UpdatePlanRequest{
|
|
||||||
Name: updatedName,
|
|
||||||
Description: "Updated Description",
|
|
||||||
ExecutionType: models.PlanExecutionTypeAutomatic,
|
|
||||||
ContentType: models.PlanContentTypeTasks,
|
|
||||||
}
|
|
||||||
bodyBytes, _ := json.Marshal(reqBody)
|
|
||||||
|
|
||||||
// 创建 HTTP PUT 请求
|
|
||||||
req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
// 发送 HTTP 请求到路由器
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
// 验证 HTTP 状态码
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
// 解析响应体
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// 验证业务响应码、消息和返回数据
|
|
||||||
assert.Equal(t, controller.CodeSuccess, resp.Code)
|
|
||||||
assert.Equal(t, "计划更新成功", resp.Message)
|
|
||||||
|
|
||||||
dataMap, ok := resp.Data.(map[string]interface{})
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, float64(planID), dataMap["id"])
|
|
||||||
assert.Equal(t, updatedName, dataMap["name"])
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("失败-无效的ID格式", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
// 模拟仓库为空,因为预期不会调用仓库方法
|
|
||||||
mockRepo := &MockPlanRepository{}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
// 创建带有无效ID格式的 HTTP PUT 请求
|
|
||||||
req, _ := http.NewRequest(http.MethodPut, "/plans/abc", nil)
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeBadRequest, resp.Code)
|
|
||||||
assert.Equal(t, "无效的计划ID格式", resp.Message)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("失败-请求体绑定失败", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
planID := uint(1)
|
|
||||||
// 模拟仓库为空,因为预期不会调用仓库方法(请求体绑定失败发生在控制器内部)
|
|
||||||
mockRepo := &MockPlanRepository{}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
|
|
||||||
// 准备一个无效的 JSON 请求体,例如 execution_type 类型错误
|
|
||||||
reqBody := `{\"name\": \"Updated Plan Name\",}`
|
|
||||||
req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBufferString(reqBody))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeBadRequest, resp.Code)
|
|
||||||
assert.Contains(t, resp.Message, "无效的请求体")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("失败-计划不存在", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
planID := uint(999)
|
|
||||||
// 模拟仓库行为:GetBasicPlanByID 返回记录未找到错误
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) {
|
|
||||||
assert.Equal(t, planID, id)
|
|
||||||
return nil, gorm.ErrRecordNotFound
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
|
|
||||||
// 准备有效的请求体
|
|
||||||
reqBody := UpdatePlanRequest{
|
|
||||||
Name: "Updated Plan Name",
|
|
||||||
Description: "Updated Description",
|
|
||||||
ExecutionType: models.PlanExecutionTypeAutomatic,
|
|
||||||
ContentType: models.PlanContentTypeTasks,
|
|
||||||
}
|
|
||||||
bodyBytes, _ := json.Marshal(reqBody)
|
|
||||||
|
|
||||||
// 创建 HTTP PUT 请求
|
|
||||||
req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeNotFound, resp.Code)
|
|
||||||
assert.Equal(t, "计划不存在", resp.Message)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("失败-计划数据校验失败", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
planID := uint(1)
|
|
||||||
// 模拟一个已存在的计划
|
|
||||||
mockPlan := &models.Plan{
|
|
||||||
Model: gorm.Model{ID: planID},
|
|
||||||
Name: "Original Plan",
|
|
||||||
Description: "Original Description",
|
|
||||||
ContentType: models.PlanContentTypeTasks,
|
|
||||||
}
|
|
||||||
// 配置模拟仓库行为:GetBasicPlanByID 成功返回现有计划
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) {
|
|
||||||
return mockPlan, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
|
|
||||||
// 准备一个会导致 PlanFromUpdateRequest 校验失败的请求体。
|
|
||||||
// 这里通过提供重复的 ExecutionOrder 来触发 ValidateExecutionOrder 错误。
|
|
||||||
reqBody := UpdatePlanRequest{
|
|
||||||
Name: "Invalid Plan",
|
|
||||||
ExecutionType: models.PlanExecutionTypeAutomatic,
|
|
||||||
ContentType: models.PlanContentTypeTasks, // 设置为任务类型
|
|
||||||
Tasks: []TaskRequest{
|
|
||||||
{Name: "Task 1", ExecutionOrder: 1, Type: models.TaskTypeWaiting},
|
|
||||||
{Name: "Task 2", ExecutionOrder: 1, Type: models.TaskTypeWaiting}, // 重复的执行顺序
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bodyBytes, _ := json.Marshal(reqBody)
|
|
||||||
|
|
||||||
// 创建 HTTP PUT 请求
|
|
||||||
req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeBadRequest, resp.Code)
|
|
||||||
assert.Contains(t, resp.Message, "计划数据校验失败")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("失败-仓库层更新失败", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
planID := uint(1)
|
|
||||||
// 模拟一个已存在的计划
|
|
||||||
mockPlan := &models.Plan{
|
|
||||||
Model: gorm.Model{ID: planID},
|
|
||||||
Name: "Original Plan",
|
|
||||||
Description: "Original Description",
|
|
||||||
ContentType: models.PlanContentTypeTasks,
|
|
||||||
}
|
|
||||||
updateErr := errors.New("failed to update in repository")
|
|
||||||
// 配置模拟仓库行为
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
// 模拟 GetBasicPlanByID 成功返回现有计划
|
|
||||||
GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) {
|
|
||||||
return mockPlan, nil
|
|
||||||
},
|
|
||||||
// 模拟 UpdatePlan 返回更新失败错误
|
|
||||||
UpdatePlanFunc: func(plan *models.Plan) error {
|
|
||||||
return updateErr // 模拟更新失败
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
|
|
||||||
// 准备有效的请求体
|
|
||||||
reqBody := UpdatePlanRequest{
|
|
||||||
Name: "Updated Plan Name",
|
|
||||||
Description: "Updated Description",
|
|
||||||
ExecutionType: models.PlanExecutionTypeAutomatic,
|
|
||||||
ContentType: models.PlanContentTypeTasks,
|
|
||||||
}
|
|
||||||
bodyBytes, _ := json.Marshal(reqBody)
|
|
||||||
|
|
||||||
// 创建 HTTP PUT 请求
|
|
||||||
req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeBadRequest, resp.Code)
|
|
||||||
assert.Equal(t, "更新计划失败: "+updateErr.Error(), resp.Message)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("失败-获取更新后计划失败", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
planID := uint(1)
|
|
||||||
// 模拟一个已存在的计划
|
|
||||||
mockPlan := &models.Plan{
|
|
||||||
Model: gorm.Model{ID: planID},
|
|
||||||
Name: "Original Plan",
|
|
||||||
Description: "Original Description",
|
|
||||||
ContentType: models.PlanContentTypeTasks,
|
|
||||||
}
|
|
||||||
getUpdatedErr := errors.New("failed to get updated plan from repository")
|
|
||||||
// 配置模拟仓库行为
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
// 模拟 GetBasicPlanByID 成功返回现有计划
|
|
||||||
GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) {
|
|
||||||
return mockPlan, nil
|
|
||||||
},
|
|
||||||
// 模拟 UpdatePlan 成功
|
|
||||||
UpdatePlanFunc: func(plan *models.Plan) error {
|
|
||||||
return nil // 模拟成功更新
|
|
||||||
},
|
|
||||||
// 模拟 GetPlanByID 返回获取失败错误
|
|
||||||
GetPlanByIDFunc: func(id uint) (*models.Plan, error) {
|
|
||||||
return nil, getUpdatedErr // 模拟获取更新后计划失败
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
|
|
||||||
// 准备有效的请求体
|
|
||||||
reqBody := UpdatePlanRequest{
|
|
||||||
Name: "Updated Plan Name",
|
|
||||||
Description: "Updated Description",
|
|
||||||
ExecutionType: models.PlanExecutionTypeAutomatic,
|
|
||||||
ContentType: models.PlanContentTypeTasks,
|
|
||||||
}
|
|
||||||
bodyBytes, _ := json.Marshal(reqBody)
|
|
||||||
|
|
||||||
// 创建 HTTP PUT 请求
|
|
||||||
req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeInternalError, resp.Code)
|
|
||||||
assert.Equal(t, "获取更新后计划详情时发生内部错误", resp.Message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestController_DeletePlan 是 DeletePlan 的单元测试
|
|
||||||
func TestController_DeletePlan(t *testing.T) {
|
|
||||||
t.Run("成功-删除计划", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
// 模拟仓库行为:DeletePlan 成功
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
DeletePlanFunc: func(id uint) error {
|
|
||||||
assert.Equal(t, uint(1), id)
|
|
||||||
return nil // 模拟成功删除
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodDelete, "/plans/1", nil)
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeSuccess, resp.Code)
|
|
||||||
assert.Equal(t, "计划删除成功", resp.Message)
|
|
||||||
assert.Nil(t, resp.Data)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("失败-计划不存在", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
// 模拟仓库行为:DeletePlan 返回记录未找到错误
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
DeletePlanFunc: func(id uint) error {
|
|
||||||
return gorm.ErrRecordNotFound // 模拟未找到记录
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodDelete, "/plans/999", nil)
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeInternalError, resp.Code)
|
|
||||||
assert.Equal(t, "删除计划时发生内部错误", resp.Message)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("失败-无效的ID格式", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
// 模拟仓库为空,因为预期不会调用仓库方法
|
|
||||||
mockRepo := &MockPlanRepository{}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
// 创建带有无效ID格式的 HTTP DELETE 请求
|
|
||||||
req, _ := http.NewRequest(http.MethodDelete, "/plans/abc", nil)
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeBadRequest, resp.Code)
|
|
||||||
assert.Equal(t, "无效的计划ID格式", resp.Message)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("失败-仓库层内部错误", func(t *testing.T) {
|
|
||||||
// Arrange (准备阶段)
|
|
||||||
internalErr := errors.New("something went wrong")
|
|
||||||
// 模拟仓库行为:DeletePlan 返回内部错误
|
|
||||||
mockRepo := &MockPlanRepository{
|
|
||||||
|
|
||||||
DeletePlanFunc: func(id uint) error {
|
|
||||||
return internalErr // 模拟内部错误
|
|
||||||
},
|
|
||||||
}
|
|
||||||
router := setupTestRouter(mockRepo)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodDelete, "/plans/1", nil)
|
|
||||||
|
|
||||||
// Act (执行阶段)
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Assert (断言阶段)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp controller.Response
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, controller.CodeInternalError, resp.Code)
|
|
||||||
assert.Equal(t, "删除计划时发生内部错误", resp.Message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
package user_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
|
|
||||||
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/user"
|
|
||||||
"git.huangwc.com/pig/pig-farm-controller/internal/app/service/token"
|
|
||||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
|
|
||||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockUserRepository 是 UserRepository 接口的模拟实现
|
|
||||||
type MockUserRepository struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateTx 模拟 UserRepository 的 CreateTx 方法
|
|
||||||
func (m *MockUserRepository) Create(user *models.User) error {
|
|
||||||
args := m.Called(user)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindByUsername 模拟 UserRepository 的 FindByUsername 方法
|
|
||||||
// 返回类型改回 *models.User
|
|
||||||
func (m *MockUserRepository) FindByUsername(username string) (*models.User, error) {
|
|
||||||
args := m.Called(username)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(*models.User), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindByID 模拟 UserRepository 的 FindByID 方法
|
|
||||||
func (m *MockUserRepository) FindByID(id uint) (*models.User, error) {
|
|
||||||
args := m.Called(id)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(*models.User), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockTokenService 是 token.TokenService 接口的模拟实现
|
|
||||||
type MockTokenService struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateToken 模拟 TokenService 的 GenerateToken 方法
|
|
||||||
func (m *MockTokenService) GenerateToken(userID uint) (string, error) {
|
|
||||||
args := m.Called(userID)
|
|
||||||
return args.String(0), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseToken 模拟 TokenService 的 ParseToken 方法
|
|
||||||
func (m *MockTokenService) ParseToken(tokenString string) (*token.Claims, error) {
|
|
||||||
args := m.Called(tokenString)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(*token.Claims), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCreateUser 测试 CreateUser 方法
|
|
||||||
func TestCreateUser(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode) // 设置 Gin 为测试模式
|
|
||||||
|
|
||||||
// 创建一个不输出日志的真实 logs.Logger 实例
|
|
||||||
silentLogger := logs.NewSilentLogger()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
requestBody user.CreateUserRequest
|
|
||||||
mockRepoSetup func(*MockUserRepository)
|
|
||||||
expectedResponse map[string]interface{}
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "成功创建用户",
|
|
||||||
requestBody: user.CreateUserRequest{
|
|
||||||
Username: "testuser",
|
|
||||||
Password: "password123",
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockUserRepository) {
|
|
||||||
// 模拟 CreateTx 成功
|
|
||||||
m.On("CreateTx", mock.AnythingOfType("*models.User")).Return(nil).Run(func(args mock.Arguments) {
|
|
||||||
// 模拟数据库自动填充 ID
|
|
||||||
userArg := args.Get(0).(*models.User)
|
|
||||||
userArg.ID = 1 // 设置一个非零的 ID
|
|
||||||
}).Once()
|
|
||||||
// 在成功创建用户的路径下,FindByUsername 不会被调用,因此这里不需要设置其期望
|
|
||||||
},
|
|
||||||
expectedResponse: map[string]interface{}{
|
|
||||||
"code": float64(controller.CodeCreated), // 修改这里:使用自定义状态码
|
|
||||||
"message": "用户创建成功",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"username": "testuser",
|
|
||||||
// "id": mock.Anything, // 移除这里的 id,在断言时单独检查
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "请求参数绑定失败_密码过短",
|
|
||||||
requestBody: user.CreateUserRequest{
|
|
||||||
Username: "testuser2",
|
|
||||||
Password: "123", // 密码少于6位
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockUserRepository) {
|
|
||||||
// 不会调用 CreateTx 或 FindByUsername
|
|
||||||
},
|
|
||||||
expectedResponse: map[string]interface{}{
|
|
||||||
"code": float64(controller.CodeBadRequest),
|
|
||||||
"message": "Key: 'CreateUserRequest.Password' Error:Field validation for 'Password' failed on the 'min' tag",
|
|
||||||
"data": nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "请求参数绑定失败_缺少用户名",
|
|
||||||
requestBody: user.CreateUserRequest{
|
|
||||||
Password: "password123",
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockUserRepository) {
|
|
||||||
// 不会调用 CreateTx 或 FindByUsername
|
|
||||||
},
|
|
||||||
expectedResponse: map[string]interface{}{
|
|
||||||
"code": float64(controller.CodeBadRequest),
|
|
||||||
"message": "Key: 'CreateUserRequest.Username' Error:Field validation for 'Username' failed on the 'required' tag",
|
|
||||||
"data": nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "用户名已存在",
|
|
||||||
requestBody: user.CreateUserRequest{
|
|
||||||
Username: "existinguser",
|
|
||||||
Password: "password123",
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockUserRepository) {
|
|
||||||
// 模拟 CreateTx 失败,因为用户名已存在
|
|
||||||
m.On("CreateTx", mock.AnythingOfType("*models.User")).Return(errors.New("duplicate entry")).Once()
|
|
||||||
// 模拟 FindByUsername 找到用户,确认是用户名重复
|
|
||||||
m.On("FindByUsername", "existinguser").Return(&models.User{Username: "existinguser"}, nil).Once()
|
|
||||||
},
|
|
||||||
expectedResponse: map[string]interface{}{
|
|
||||||
"code": float64(controller.CodeConflict),
|
|
||||||
"message": "用户名已存在",
|
|
||||||
"data": nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "创建用户失败_通用数据库错误",
|
|
||||||
requestBody: user.CreateUserRequest{
|
|
||||||
Username: "db_error_user",
|
|
||||||
Password: "password123",
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockUserRepository) {
|
|
||||||
// 模拟 CreateTx 失败,通用数据库错误
|
|
||||||
m.On("CreateTx", mock.AnythingOfType("*models.User")).Return(errors.New("database error")).Once()
|
|
||||||
// 模拟 FindByUsername 找不到用户,确认不是用户名重复
|
|
||||||
m.On("FindByUsername", "db_error_user").Return(nil, gorm.ErrRecordNotFound).Once()
|
|
||||||
},
|
|
||||||
expectedResponse: map[string]interface{}{
|
|
||||||
"code": float64(controller.CodeInternalError),
|
|
||||||
"message": "创建用户失败",
|
|
||||||
"data": nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// 初始化 Gin 上下文和记录器
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
ctx, _ := gin.CreateTestContext(w)
|
|
||||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/users", nil) // URL 路径不重要,因为我们不测试路由
|
|
||||||
|
|
||||||
// 设置请求体
|
|
||||||
jsonBody, _ := json.Marshal(tt.requestBody)
|
|
||||||
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(jsonBody))
|
|
||||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
// 创建 Mock UserRepository
|
|
||||||
mockRepo := new(MockUserRepository)
|
|
||||||
|
|
||||||
// 设置 Mock UserRepository 行为
|
|
||||||
tt.mockRepoSetup(mockRepo)
|
|
||||||
|
|
||||||
// 创建控制器实例,使用静默日志器
|
|
||||||
userController := user.NewController(mockRepo, silentLogger, nil) // tokenService 在 CreateUser 中未使用,设为 nil
|
|
||||||
|
|
||||||
// 调用被测试的方法
|
|
||||||
userController.CreateUser(ctx)
|
|
||||||
|
|
||||||
// 解析响应体
|
|
||||||
var responseBody map[string]interface{}
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &responseBody)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// 断言响应体中的 code 字段
|
|
||||||
assert.Equal(t, tt.expectedResponse["code"], responseBody["code"])
|
|
||||||
|
|
||||||
// 断言响应内容 (除了 code 字段)
|
|
||||||
if tt.expectedResponse["code"] == float64(controller.CodeCreated) {
|
|
||||||
// 确保 data 字段存在且是 map[string]interface{} 类型
|
|
||||||
data, ok := responseBody["data"].(map[string]interface{})
|
|
||||||
assert.True(t, ok, "响应体中的 data 字段应为 map[string]interface{}")
|
|
||||||
// 确保 id 字段存在且不为零
|
|
||||||
id, idOk := data["id"].(float64)
|
|
||||||
assert.True(t, idOk, "响应体中的 data.id 字段应为 float64 类型")
|
|
||||||
assert.NotEqual(t, float64(0), id, "响应体中的 data.id 不应为零")
|
|
||||||
|
|
||||||
// 移除 ID 字段以便进行通用断言
|
|
||||||
delete(responseBody["data"].(map[string]interface{}), "id")
|
|
||||||
// 移除 expectedResponse 中的 id 字段,因为我们已经单独验证了
|
|
||||||
if expectedData, ok := tt.expectedResponse["data"].(map[string]interface{}); ok {
|
|
||||||
delete(expectedData, "id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 移除 code 字段以便进行通用断言
|
|
||||||
delete(responseBody, "code")
|
|
||||||
delete(tt.expectedResponse, "code")
|
|
||||||
assert.Equal(t, tt.expectedResponse, responseBody)
|
|
||||||
|
|
||||||
// 验证 Mock 期望是否都已满足
|
|
||||||
mockRepo.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLogin 测试 Login 方法
|
|
||||||
func TestLogin(t *testing.T) {
|
|
||||||
// 设置release模式阻止废话日志
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
|
||||||
|
|
||||||
// 创建一个不输出日志的真实 logs.Logger 实例
|
|
||||||
silentLogger := logs.NewSilentLogger()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
requestBody user.LoginRequest
|
|
||||||
mockRepoSetup func(*MockUserRepository)
|
|
||||||
mockTokenServiceSetup func(*MockTokenService)
|
|
||||||
expectedResponse map[string]interface{}
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "成功登录",
|
|
||||||
requestBody: user.LoginRequest{
|
|
||||||
Username: "loginuser",
|
|
||||||
Password: "correctpassword",
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockUserRepository) {
|
|
||||||
mockUser := &models.User{
|
|
||||||
Model: gorm.Model{ID: 1},
|
|
||||||
Username: "loginuser",
|
|
||||||
Password: "correctpassword", // 明文密码,BeforeCreate 会哈希它
|
|
||||||
}
|
|
||||||
// 调用 BeforeCreate 钩子来哈希密码
|
|
||||||
_ = mockUser.BeforeCreate(nil)
|
|
||||||
m.On("FindByUsername", "loginuser").Return(mockUser, nil).Once()
|
|
||||||
},
|
|
||||||
mockTokenServiceSetup: func(m *MockTokenService) {
|
|
||||||
m.On("GenerateToken", uint(1)).Return("mocked_token", nil).Once()
|
|
||||||
},
|
|
||||||
expectedResponse: map[string]interface{}{
|
|
||||||
"code": float64(controller.CodeSuccess),
|
|
||||||
"message": "登录成功",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"username": "loginuser",
|
|
||||||
"id": float64(1),
|
|
||||||
"token": "mocked_token",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "请求参数绑定失败_缺少用户名",
|
|
||||||
requestBody: user.LoginRequest{
|
|
||||||
Username: "", // 缺少用户名
|
|
||||||
Password: "password",
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockUserRepository) {},
|
|
||||||
mockTokenServiceSetup: func(m *MockTokenService) {},
|
|
||||||
expectedResponse: map[string]interface{}{
|
|
||||||
"code": float64(controller.CodeBadRequest),
|
|
||||||
"message": "Key: 'LoginRequest.Username' Error:Field validation for 'Username' failed on the 'required' tag",
|
|
||||||
"data": nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "请求参数绑定失败_缺少密码",
|
|
||||||
requestBody: user.LoginRequest{
|
|
||||||
Username: "testuser",
|
|
||||||
Password: "", // 缺少密码
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockUserRepository) {},
|
|
||||||
mockTokenServiceSetup: func(m *MockTokenService) {},
|
|
||||||
expectedResponse: map[string]interface{}{
|
|
||||||
"code": float64(controller.CodeBadRequest),
|
|
||||||
"message": "Key: 'LoginRequest.Password' Error:Field validation for 'Password' failed on the 'required' tag",
|
|
||||||
"data": nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "用户不存在",
|
|
||||||
requestBody: user.LoginRequest{
|
|
||||||
Username: "nonexistent",
|
|
||||||
Password: "anypassword",
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockUserRepository) {
|
|
||||||
m.On("FindByUsername", "nonexistent").Return(nil, gorm.ErrRecordNotFound).Once()
|
|
||||||
},
|
|
||||||
mockTokenServiceSetup: func(m *MockTokenService) {},
|
|
||||||
expectedResponse: map[string]interface{}{
|
|
||||||
"code": float64(controller.CodeUnauthorized),
|
|
||||||
"message": "用户名或密码不正确",
|
|
||||||
"data": nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "查询用户失败_通用数据库错误",
|
|
||||||
requestBody: user.LoginRequest{
|
|
||||||
Username: "dberroruser",
|
|
||||||
Password: "password",
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockUserRepository) {
|
|
||||||
m.On("FindByUsername", "dberroruser").Return(nil, errors.New("database connection error")).Once()
|
|
||||||
},
|
|
||||||
mockTokenServiceSetup: func(m *MockTokenService) {}, expectedResponse: map[string]interface{}{
|
|
||||||
"code": float64(controller.CodeInternalError),
|
|
||||||
"message": "登录失败",
|
|
||||||
"data": nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "密码不正确",
|
|
||||||
requestBody: user.LoginRequest{
|
|
||||||
Username: "loginuser",
|
|
||||||
Password: "wrongpassword",
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockUserRepository) {
|
|
||||||
mockUser := &models.User{
|
|
||||||
Model: gorm.Model{ID: 1},
|
|
||||||
Username: "loginuser",
|
|
||||||
Password: "correctpassword", // 明文密码,BeforeCreate 会哈希它
|
|
||||||
}
|
|
||||||
// 调用 BeforeCreate 钩子来哈希密码
|
|
||||||
_ = mockUser.BeforeCreate(nil)
|
|
||||||
m.On("FindByUsername", "loginuser").Return(mockUser, nil).Once()
|
|
||||||
},
|
|
||||||
mockTokenServiceSetup: func(m *MockTokenService) {},
|
|
||||||
expectedResponse: map[string]interface{}{
|
|
||||||
"code": float64(controller.CodeUnauthorized),
|
|
||||||
"message": "用户名或密码不正确",
|
|
||||||
"data": nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "生成Token失败",
|
|
||||||
requestBody: user.LoginRequest{
|
|
||||||
Username: "loginuser",
|
|
||||||
Password: "correctpassword",
|
|
||||||
},
|
|
||||||
mockRepoSetup: func(m *MockUserRepository) {
|
|
||||||
mockUser := &models.User{
|
|
||||||
Model: gorm.Model{ID: 1},
|
|
||||||
Username: "loginuser",
|
|
||||||
Password: "correctpassword", // 明文密码,BeforeCreate 会哈希它
|
|
||||||
}
|
|
||||||
// 调用 BeforeCreate 钩子来哈希密码
|
|
||||||
_ = mockUser.BeforeCreate(nil)
|
|
||||||
m.On("FindByUsername", "loginuser").Return(mockUser, nil).Once()
|
|
||||||
},
|
|
||||||
mockTokenServiceSetup: func(m *MockTokenService) {
|
|
||||||
m.On("GenerateToken", uint(1)).Return("", errors.New("jwt error")).Once()
|
|
||||||
},
|
|
||||||
expectedResponse: map[string]interface{}{
|
|
||||||
"code": float64(controller.CodeInternalError),
|
|
||||||
"message": "登录失败,无法生成认证信息",
|
|
||||||
"data": nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// 初始化 Gin 上下文和记录器
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
ctx, _ := gin.CreateTestContext(w)
|
|
||||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/login", nil) // URL 路径不重要,因为我们不测试路由
|
|
||||||
|
|
||||||
// 设置请求体
|
|
||||||
jsonBody, _ := json.Marshal(tt.requestBody)
|
|
||||||
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(jsonBody))
|
|
||||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
// 创建 Mock
|
|
||||||
mockRepo := new(MockUserRepository)
|
|
||||||
mockTokenService := new(MockTokenService)
|
|
||||||
|
|
||||||
// 设置 Mock 行为
|
|
||||||
tt.mockRepoSetup(mockRepo)
|
|
||||||
tt.mockTokenServiceSetup(mockTokenService)
|
|
||||||
|
|
||||||
// 创建控制器实例
|
|
||||||
userController := user.NewController(mockRepo, silentLogger, mockTokenService)
|
|
||||||
|
|
||||||
// 调用被测试的方法
|
|
||||||
userController.Login(ctx)
|
|
||||||
|
|
||||||
// 解析响应体
|
|
||||||
var responseBody map[string]interface{}
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &responseBody)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// 断言响应体中的 code 字段
|
|
||||||
assert.Equal(t, tt.expectedResponse["code"], responseBody["code"])
|
|
||||||
|
|
||||||
// 断言响应内容 (除了 code 字段)
|
|
||||||
if tt.expectedResponse["code"] == float64(controller.CodeSuccess) {
|
|
||||||
// 确保 data 字段存在且是 map[string]interface{} 类型
|
|
||||||
data, ok := responseBody["data"].(map[string]interface{})
|
|
||||||
assert.True(t, ok, "响应体中的 data 字段应为 map[string]interface{}")
|
|
||||||
|
|
||||||
// 验证 id 和 token 存在
|
|
||||||
assert.NotNil(t, data["id"])
|
|
||||||
assert.NotNil(t, data["token"])
|
|
||||||
|
|
||||||
// 移除 ID 和 Token 字段以便进行通用断言
|
|
||||||
delete(responseBody["data"].(map[string]interface{}), "id")
|
|
||||||
delete(tt.expectedResponse["data"].(map[string]interface{}), "id")
|
|
||||||
delete(responseBody["data"].(map[string]interface{}), "token")
|
|
||||||
delete(tt.expectedResponse["data"].(map[string]interface{}), "token")
|
|
||||||
}
|
|
||||||
// 移除 code 字段以便进行通用断言
|
|
||||||
delete(responseBody, "code")
|
|
||||||
delete(tt.expectedResponse, "code")
|
|
||||||
assert.Equal(t, tt.expectedResponse, responseBody)
|
|
||||||
|
|
||||||
// 验证 Mock 期望是否都已满足
|
|
||||||
mockRepo.AssertExpectations(t)
|
|
||||||
mockTokenService.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
78
openspec/changes/refactor-migrate-gin-to-echo/design.md
Normal file
78
openspec/changes/refactor-migrate-gin-to-echo/design.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
当前 API 服务基于 Gin 构建。本次任务的目标是将其完整迁移到 Echo 框架,同时保持功能和接口的完全向后兼容。这包括路由、请求处理、中间件、Swagger 文档和 pprof 分析工具。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
- **Goals**:
|
||||||
|
- 成功将 Web 框架从 Gin 迁移到 Echo v4。
|
||||||
|
- 保持所有现有 API 端点的路径、方法和行为不变。
|
||||||
|
- 确保所有自定义中间件(认证、审计日志)功能正常。
|
||||||
|
- 确保 Swagger UI 可以在 `/swagger/index.html` 正常访问。
|
||||||
|
- 确保 pprof 调试端点在 `/debug/pprof/*` 路径下正常工作。
|
||||||
|
- **Non-Goals**:
|
||||||
|
- 增加任何新的 API 端点或功能。
|
||||||
|
- 修改任何现有的 API 请求/响应模型。
|
||||||
|
- 在本次变更中引入新的业务逻辑。
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
以下是从 Gin 到 Echo 的关键组件映射决策:
|
||||||
|
|
||||||
|
1. **框架实例**:
|
||||||
|
- **From**: `gin.SetMode(cfg.Mode)`, `engine := gin.New()`, `engine.Use(gin.Recovery())`
|
||||||
|
- **To**: `e := echo.New()`, `e.Debug = (cfg.Mode == "debug")`, `e.Use(middleware.Recover())`
|
||||||
|
- **Rationale**: `echo.New()` 提供了干净的实例。Echo 的 `Debug` 属性控制调试模式,可以根据配置设置。Echo 提供了内置的 `middleware.Recover()` 来替代 Gin 的 Recovery 中间件。
|
||||||
|
- **Implementation**:
|
||||||
|
- 在 `internal/app/api/api.go` 中,将 `engine *gin.Engine` 替换为 `engine *echo.Echo`,并更新 `NewAPI` 方法中的初始化逻辑。
|
||||||
|
- 在 `config.yml` 和 `config.example.yml` 中, 更新关于 `mode` 配置项的注释, 将 "Gin 运行模式" 修改为 "服务运行模式", 因为该配置项现在控制 Echo 的调试模式。
|
||||||
|
|
||||||
|
2. **上下文对象 (Context) 与处理器签名**:
|
||||||
|
- **From**: `func(c *gin.Context)`
|
||||||
|
- **To**: `func(c echo.Context) error`
|
||||||
|
- **Rationale**: 这是两个框架的核心区别。所有控制器处理函数签名都需要更新。常见方法映射如下:
|
||||||
|
- `ctx.ShouldBindJSON(&req)` -> `c.Bind(&req)` (Echo 的 `Bind` 更通用,能根据 `Content-Type` 自动选择解析器)
|
||||||
|
- `ctx.ShouldBindQuery(&req)` -> `c.Bind(&req)`
|
||||||
|
- `ctx.Param("id")` -> `c.Param("id")` (签名相同)
|
||||||
|
- `ctx.GetHeader("Authorization")` -> `c.Request().Header.Get("Authorization")`
|
||||||
|
- `ctx.Set("key", value)` -> `c.Set("key", value)` (签名相同)
|
||||||
|
- `ctx.Get("key")` -> `c.Get("key")` (签名相同)
|
||||||
|
- `ctx.ClientIP()` -> `c.RealIP()`
|
||||||
|
- `controller.SendResponse(ctx, ...)` -> `return controller.SendResponse(c, ...)` (控制器方法需要返回 `error`,辅助函数也需要修改以返回 `error`)
|
||||||
|
- `controller.SendErrorResponse(ctx, ...)` -> `return controller.SendErrorResponse(c, ...)` (同上)
|
||||||
|
- `ctx.AbortWithStatusJSON(...)` -> `return c.JSON(...)` (在中间件中,通过 `return c.JSON(...)` 来中断链并响应)
|
||||||
|
|
||||||
|
3. **中间件 (Middleware)**:
|
||||||
|
- **From**: `func AuthMiddleware(...) gin.HandlerFunc { return func(c *gin.Context) { ... } }`
|
||||||
|
- **To**: `func AuthMiddleware(...) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { ...; return next(c) } } }`
|
||||||
|
- **Rationale**: Echo 的中间件是一个包装器模式。我们需要将现有的 `AuthMiddleware` 和 `AuditLogMiddleware` 逻辑迁移到这个新的结构中。中断请求链的方式从 `c.AbortWithStatusJSON()` 变为从处理函数中 `return c.JSON(...)`。
|
||||||
|
|
||||||
|
4. **Swagger 集成**:
|
||||||
|
- **From**: `github.com/swaggo/gin-swagger`
|
||||||
|
- **To**: `github.com/swaggo/echo-swagger`
|
||||||
|
- **Rationale**: 这是 `swaggo` 官方为 Echo 提供的适配库,可以无缝替换。
|
||||||
|
- **Implementation**: 在 `router.go` 中使用 `e.GET("/swagger/*", echoSwagger.WrapHandler)`。
|
||||||
|
|
||||||
|
5. **Pprof 与其他 `net/http` 处理器集成**:
|
||||||
|
- **From**: `gin.WrapH` 和 `gin.WrapF`
|
||||||
|
- **To**: `echo.WrapHandler` 和 `echo.WrapFunc`
|
||||||
|
- **Rationale**: Echo 提供了类似的 `net/http` 处理器包装函数,可以轻松集成 pprof 和项目中的 `listenHandler`。
|
||||||
|
- **Implementation**: 在 `router.go` 中替换所有 `gin.WrapH` 和 `gin.WrapF` 的调用。
|
||||||
|
|
||||||
|
6. **控制器辅助函数**:
|
||||||
|
- **Affected Files**:
|
||||||
|
- `internal/app/controller/response.go`
|
||||||
|
- `internal/app/controller/auth_utils.go`
|
||||||
|
- `internal/app/controller/management/controller_helpers.go`
|
||||||
|
- **Change**:
|
||||||
|
- 在 `response.go` 和 `auth_utils.go` 中, 所有接收 `*gin.Context` 的辅助函数 (如 `SendResponse`, `GetOperatorIDFromContext` 等) 签名都需要修改为接收 `echo.Context`。
|
||||||
|
- 在 `controller_helpers.go` 中, `handle...` 系列的泛型辅助函数 (如 `handleAPIRequest`, `handleNoBodyAPIRequest` 等) 及其依赖的 `extractOperatorAndPrimaryID` 和 `mapAndSendError` 函数, 都需要将其中的 `*gin.Context` 参数和相关调用 (如 `ShouldBindJSON`) 替换为 `echo.Context` 的等效实现。
|
||||||
|
- 所有这些辅助函数, 如果它们原本不返回 `error`, 现在需要修改为返回 `error`, 以便与 Echo 的处理器错误链兼容。例如, `SendResponse` 这类函数在调用 `c.JSON(...)` 后, 最终应 `return nil`。
|
||||||
|
- **Rationale**: 这些辅助函数封装了请求处理、响应发送和错误处理的核心逻辑, 必须进行适配以兼容 Echo 的 `echo.Context` 上下文对象和 `return error` 的错误处理模式。
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Risk**: 迁移工作量大,可能遗漏某些 Gin 特有的功能或上下文用法,导致运行时错误。
|
||||||
|
- **Mitigation**: 采用逐个文件、逐个控制器修改的方式,每修改完一部分就进行编译检查。在完成所有编码后,进行全面的手动 API 测试。
|
||||||
|
- **Risk**: `AuditLogMiddleware` 中间件依赖 `bodyLogWriter` 捕获响应体,需要验证其与 Echo 的 `ResponseWriter` 是否兼容或需要寻找替代方案。
|
||||||
|
- **Mitigation**: 在迁移中间件时,优先研究 Echo 官方推荐的 Body Dump 或类似中间件,如果不适用,再尝试适配 `bodyLogWriter`。
|
||||||
26
openspec/changes/refactor-migrate-gin-to-echo/proposal.md
Normal file
26
openspec/changes/refactor-migrate-gin-to-echo/proposal.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
本项目当前使用 Gin 作为核心 Web 框架。Gin 的路由系统存在一些限制,例如无法优雅地支持类似 `/:id/action` 和 `/:other_id/other-action` 这种在同一层级使用不同动态参数的路由模式。为了解决此问题并利用更现代、灵活的路由和中间件系统,我们计划将框架迁移到 Echo (v4)。本次变更仅进行框架替换,暂不修改现有路由结构。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **核心框架替换**: 将 `github.com/gin-gonic/gin` 的所有引用替换为 `github.com/labstack/echo/v4`。
|
||||||
|
- **API 路由重写**: 更新 `internal/app/api/router.go` 以使用 Echo 的路由注册方式。
|
||||||
|
- **上下文对象适配**: 在所有 Controller 和 Middleware 中,将 `*gin.Context` 替换为 `echo.Context`,并调整相关方法调用。
|
||||||
|
- **中间件迁移**: 将现有的 Gin 中间件 (`AuthMiddleware`, `AuditLogMiddleware`) 适配为 Echo 的中间件格式。
|
||||||
|
- **Swagger 文档适配**: 将 `gin-swagger` 替换为 Echo 兼容的 `echo-swagger`,确保 API 文档能够正常生成和访问。
|
||||||
|
- **Pprof 路由适配**: 确保性能分析工具 pprof 的路由在 Echo 框架下正常工作。
|
||||||
|
|
||||||
|
**BREAKING**: 这是一项纯粹的技术栈重构,**不应该**对外部 API 消费者产生任何破坏性影响。所有 API 端点、请求/响应格式将保持完全兼容。
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Affected specs**: 无。此变更是技术实现层面的重构,不改变任何已定义的功能规约。
|
||||||
|
- **Affected code**:
|
||||||
|
- `go.mod` / `go.sum`: 依赖项变更。
|
||||||
|
- `config.yml` / `config.example.yml`: 更新 `mode` 配置项的注释。
|
||||||
|
- `internal/app/api/api.go`
|
||||||
|
- `internal/app/api/router.go`
|
||||||
|
- `internal/app/middleware/auth.go`
|
||||||
|
- `internal/app/middleware/audit.go`
|
||||||
|
- `internal/app/controller/**/*.go`: 所有控制器及其辅助函数。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# HTTP Server Specification
|
||||||
|
|
||||||
|
本文档概述了 HTTP 服务器的需求。
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: API 服务器框架已更新
|
||||||
|
|
||||||
|
- **说明**: 底层 Web 框架从 Gin 迁移到 Echo。所有现有的 API 端点 **MUST** 保持功能齐全和向后兼容。
|
||||||
|
- **理由**: 为了提高路由灵活性并使技术栈现代化。这是一次技术重构,不会改变任何外部 API 行为。
|
||||||
|
- **影响**: 高。影响核心请求处理、路由和中间件。
|
||||||
|
- **受影响的端点**: 全部。
|
||||||
|
|
||||||
|
#### Scenario: 所有现有的 API 端点保持功能齐全和向后兼容
|
||||||
|
- **假如**: API 服务器在迁移到 Echo 后正在运行。
|
||||||
|
- **当**: 客户端向任何现有的 API 端点(例如, `POST /api/v1/users/login`)发送请求。
|
||||||
|
- **那么**: 服务器处理该请求并返回与使用 Gin 框架时完全相同的响应(状态码、头部和正文格式)。
|
||||||
60
openspec/changes/refactor-migrate-gin-to-echo/tasks.md
Normal file
60
openspec/changes/refactor-migrate-gin-to-echo/tasks.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
## 任务清单:Gin 到 Echo 迁移
|
||||||
|
|
||||||
|
- [ ] **1. 配置文件 (无代码依赖)**
|
||||||
|
- [ ] 修改 `config.yml` 中 `mode` 配置项的注释,将 "Gin 运行模式" 改为 "服务运行模式"。
|
||||||
|
- [ ] 修改 `config.example.yml` 中 `mode` 配置项的注释,保持与 `config.yml` 一致。
|
||||||
|
|
||||||
|
- [ ] **2. 控制器辅助函数 (最基础的依赖)**
|
||||||
|
- [ ] **`internal/app/controller/response.go`**
|
||||||
|
- [ ] 将 `*gin.Context` 参数全部替换为 `echo.Context`。
|
||||||
|
- [ ] 修改 `SendResponse` 和 `SendErrorResponse` 等函数,使其不再直接写入响应,而是返回 `error`,并在内部调用 `c.JSON(...)`。
|
||||||
|
- [ ] **`internal/app/controller/auth_utils.go`**
|
||||||
|
- [ ] 将 `*gin.Context` 参数全部替换为 `echo.Context`。
|
||||||
|
- [ ] 适配 `Get...FromContext` 系列函数,使用 `c.Get("key")` 提取数据。
|
||||||
|
|
||||||
|
- [ ] **3. 中间件 (`internal/app/middleware`)**
|
||||||
|
- [ ] **`auth.go`**
|
||||||
|
- [ ] 将 `import "github.com/gin-gonic/gin"` 替换为 `import "github.com/labstack/echo/v4"`。
|
||||||
|
- [ ] 将中间件函数签名从 `func AuthMiddleware(...) gin.HandlerFunc` 更新为 `func AuthMiddleware(...) echo.MiddlewareFunc`。
|
||||||
|
- [ ] 适配中间件内部逻辑,将 `func(c *gin.Context)` 改造为 `func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { ... } }` 的结构。
|
||||||
|
- [ ] 将 `c.AbortWithStatusJSON(...)` 调用替换为 `return c.JSON(...)`。
|
||||||
|
- [ ] 在逻辑正常通过的末尾,调用 `return next(c)`。
|
||||||
|
|
||||||
|
- [ ] **4. 控制器 (`internal/app/controller/...`)**
|
||||||
|
- [ ] **通用修改**:对所有控制器文件执行以下操作:
|
||||||
|
- [ ] 将 `import "github.com/gin-gonic/gin"` 替换为 `import "github.com/labstack/echo/v4"`。
|
||||||
|
- [ ] 将所有处理函数签名从 `func(c *gin.Context)` 修改为 `func(c echo.Context) error`。
|
||||||
|
- [ ] 将 `c.ShouldBindJSON(&req)` 或 `c.ShouldBindQuery(&req)` 替换为 `if err := c.Bind(&req); err != nil { ... }`。
|
||||||
|
- [ ] 将 `c.Param("id")` 替换为 `c.Param("id")` (用法相同,检查返回值即可)。
|
||||||
|
- [ ] 将 `controller.SendResponse(c, ...)` 和 `controller.SendErrorResponse(c, ...)` 调用修改为 `return controller.SendResponse(c, ...)` 和 `return controller.SendErrorResponse(c, ...)`。
|
||||||
|
- [ ] **文件清单** (按依赖顺序建议):
|
||||||
|
- [ ] `internal/app/controller/management/controller_helpers.go` (注意此文件中的泛型辅助函数也需要适配)
|
||||||
|
- [ ] `internal/app/controller/device/device_controller.go`
|
||||||
|
- [ ] `internal/app/controller/management/pig_batch_controller.go`
|
||||||
|
- [ ] `internal/app/controller/plan/plan_controller.go`
|
||||||
|
- [ ] `internal/app/controller/user/user_controller.go`
|
||||||
|
|
||||||
|
- [ ] **5. 核心 API 层 (`internal/app/api`)**
|
||||||
|
- [ ] **`router.go`**
|
||||||
|
- [ ] 将所有 `router.GET`, `router.POST` 等 Gin 路由注册方法替换为 Echo 的 `e.GET`, `e.POST` 等方法。
|
||||||
|
- [ ] 将 Swagger 路由 `router.GET("/swagger/*", ginSwagger.WrapHandler(swaggerFiles.Handler))` 替换为 `e.GET("/swagger/*", echoSwagger.WrapHandler)`。
|
||||||
|
- [ ] 将 pprof 路由的 `gin.WrapH` 和 `gin.WrapF` 调用替换为 `echo.WrapHandler` 和 `echo.WrapFunc`。
|
||||||
|
- [ ] **`api.go`**
|
||||||
|
- [ ] 将 `engine *gin.Engine` 替换为 `engine *echo.Echo`。
|
||||||
|
- [ ] 更新 `NewAPI` 函数:
|
||||||
|
- [ ] 将 `gin.SetMode(cfg.Mode)` 替换为 `e.Debug = (cfg.Mode == "debug")`。
|
||||||
|
- [ ] 将 `gin.New()` 替换为 `echo.New()`。
|
||||||
|
- [ ] 将 `engine.Use(gin.Recovery())` 替换为 `e.Use(middleware.Recover())`。
|
||||||
|
|
||||||
|
- [ ] **6. 依赖管理**
|
||||||
|
- [ ] 在 `go.mod` 中移除 `github.com/gin-gonic/gin`。
|
||||||
|
- [ ] 在 `go.mod` 中移除 `github.com/swaggo/gin-swagger`。
|
||||||
|
- [ ] 在 `go.mod` 中添加 `github.com/labstack/echo/v4`。
|
||||||
|
- [ ] 在 `go.mod` 中添加 `github.com/swaggo/echo-swagger`。
|
||||||
|
- [ ] 执行 `go mod tidy` 清理依赖项。
|
||||||
|
|
||||||
|
- [ ] **7. 验证**
|
||||||
|
- [ ] 运行 `go build ./...` 确保项目能够成功编译。
|
||||||
|
- [ ] 启动服务,手动测试所有 API 端点,验证功能是否与迁移前一致。
|
||||||
|
- [ ] 访问 `/swagger/index.html`,确认 Swagger UI 是否正常工作。
|
||||||
|
- [ ] (可选) 访问 `/debug/pprof/`,确认 pprof 路由是否正常。
|
||||||
Reference in New Issue
Block a user