Compare commits

..

3 Commits

Author SHA1 Message Date
b926f7d6a3 实现CreatePlan单测 2025-09-14 16:12:56 +08:00
389c2f9846 定义静音日志对象 2025-09-14 16:08:39 +08:00
55d32dad5f 实现CreatePlan接口 2025-09-14 15:56:55 +08:00
5 changed files with 288 additions and 38 deletions

View File

@@ -17,8 +17,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gorm.io/datatypes"
"gorm.io/gorm"
)
@@ -82,17 +80,6 @@ func (m *MockDeviceRepository) Delete(id uint) error {
return args.Error(0)
}
// getSilentLogger 创建一个不输出日志的 logs.Logger 实例,用于测试
func getSilentLogger() *logs.Logger {
discardSyncer := zapcore.AddSync(io.Discard)
encoderConfig := zap.NewProductionEncoderConfig()
encoder := zapcore.NewConsoleEncoder(encoderConfig)
core := zapcore.NewCore(encoder, discardSyncer, zap.DebugLevel)
zapLogger := zap.New(core)
sugaredLogger := zapLogger.Sugar()
return &logs.Logger{SugaredLogger: sugaredLogger}
}
// testCase 结构体定义了所有测试用例的通用参数
type testCase struct {
name string
@@ -309,7 +296,7 @@ func TestCreateDevice(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) {
device.NewController(repo, getSilentLogger()).CreateDevice(ctx)
device.NewController(repo, logs.NewSilentLogger()).CreateDevice(ctx)
})
})
}
@@ -394,7 +381,7 @@ func TestGetDevice(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) {
device.NewController(repo, getSilentLogger()).GetDevice(ctx)
device.NewController(repo, logs.NewSilentLogger()).GetDevice(ctx)
})
})
}
@@ -492,7 +479,7 @@ func TestListDevices(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) {
device.NewController(repo, getSilentLogger()).ListDevices(ctx)
device.NewController(repo, logs.NewSilentLogger()).ListDevices(ctx)
})
})
}
@@ -681,7 +668,7 @@ func TestUpdateDevice(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) {
device.NewController(repo, getSilentLogger()).UpdateDevice(ctx)
device.NewController(repo, logs.NewSilentLogger()).UpdateDevice(ctx)
})
})
}
@@ -747,7 +734,7 @@ func TestDeleteDevice(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) {
device.NewController(repo, getSilentLogger()).DeleteDevice(ctx)
device.NewController(repo, logs.NewSilentLogger()).DeleteDevice(ctx)
})
})
}

View File

@@ -107,10 +107,31 @@ func NewController(logger *logs.Logger, planRepo repository.PlanRepository) *Con
// @Success 200 {object} controller.Response{data=plan.PlanResponse} "业务码为201代表创建成功"
// @Failure 200 {object} controller.Response "业务失败具体错误码和信息见响应体例如400, 500"
// @Router /plans [post]
func (pc *Controller) CreatePlan(c *gin.Context) {
// 占位符:此处应调用服务层或仓库层来创建计划
pc.logger.Infof("收到创建计划请求 (占位符)")
controller.SendResponse(c, controller.CodeCreated, "创建计划接口占位符", PlanResponse{ID: 0, Name: "占位计划"})
func (c *Controller) CreatePlan(ctx *gin.Context) {
var req CreatePlanRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
controller.SendErrorResponse(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error())
return
}
// 使用已有的转换函数,它已经包含了验证和重排逻辑
planToCreate, err := PlanFromCreateRequest(&req)
if err != nil {
controller.SendErrorResponse(ctx, controller.CodeBadRequest, "计划数据校验失败: "+err.Error())
return
}
// 调用仓库方法创建计划
if err := c.planRepo.CreatePlan(planToCreate); err != nil {
controller.SendErrorResponse(ctx, controller.CodeBadRequest, "创建计划失败: "+err.Error())
return
}
// 使用已有的转换函数将创建后的模型转换为响应对象
resp := PlanToResponse(planToCreate)
// 使用统一的成功响应函数
controller.SendResponse(ctx, controller.CodeCreated, "计划创建成功", resp)
}
// GetPlan godoc

View File

@@ -0,0 +1,243 @@
package plan
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"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"
)
// MockPlanRepository 是 repository.PlanRepository 的一个模拟实现,用于测试
type MockPlanRepository struct {
CreatePlanFunc func(plan *models.Plan) error
// ... 可以根据需要模拟其他接口方法
}
func (m *MockPlanRepository) ListBasicPlans() ([]models.Plan, error) {
panic("implement me")
}
func (m *MockPlanRepository) GetBasicPlanByID(id uint) (*models.Plan, error) {
panic("implement me")
}
func (m *MockPlanRepository) GetPlanByID(id uint) (*models.Plan, error) {
panic("implement me")
}
func (m *MockPlanRepository) CreatePlan(plan *models.Plan) error {
if m.CreatePlanFunc != nil {
return m.CreatePlanFunc(plan)
}
return nil
}
func (m *MockPlanRepository) UpdatePlan(plan *models.Plan) error {
panic("implement me")
}
func (m *MockPlanRepository) DeletePlan(id uint) error {
panic("implement me")
}
// setupTestRouter 创建一个用于测试的 gin 引擎和控制器实例
func setupTestRouter(repo repository.PlanRepository) (*gin.Engine, *Controller) {
gin.SetMode(gin.TestMode)
router := gin.Default()
planController := NewController(logs.NewSilentLogger(), repo)
router.POST("/plans", planController.CreatePlan)
return router, planController
}
func TestController_CreatePlan(t *testing.T) {
t.Run("成功-创建包含任务的计划", func(t *testing.T) {
// Arrange
mockRepo := &MockPlanRepository{
CreatePlanFunc: func(plan *models.Plan) error {
// 模拟 GORM 回填 ID
plan.ID = 1
for i := range plan.Tasks {
plan.Tasks[i].ID = uint(i + 1)
plan.Tasks[i].PlanID = plan.ID
}
return nil
},
}
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)
req, _ := http.NewRequest(http.MethodPost, "/plans", 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.CodeCreated, resp.Code)
assert.Equal(t, "计划创建成功", resp.Message)
// 验证 Data 部分
dataMap, ok := resp.Data.(map[string]interface{})
assert.True(t, ok)
assert.Equal(t, float64(1), dataMap["id"])
assert.Equal(t, "Test Plan with Tasks", dataMap["name"])
tasks, ok := dataMap["tasks"].([]interface{})
assert.True(t, ok)
assert.Len(t, tasks, 1)
})
t.Run("失败-无效的请求体", func(t *testing.T) {
// Arrange
mockRepo := &MockPlanRepository{}
router, _ := setupTestRouter(mockRepo)
req, _ := http.NewRequest(http.MethodPost, "/plans", bytes.NewBufferString("{invalid json"))
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
mockRepo := &MockPlanRepository{}
router, _ := setupTestRouter(mockRepo)
// 创建一个带有重复执行顺序的请求,这将导致 PlanFromCreateRequest 返回错误
reqBody := CreatePlanRequest{
Name: "Duplicate Order Plan",
ExecutionType: models.PlanExecutionTypeManual,
ContentType: models.PlanContentTypeTasks,
Tasks: []TaskRequest{
{Name: "Task 1", ExecutionOrder: 1},
{Name: "Task 2", ExecutionOrder: 1}, // 重复
},
}
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, "/plans", 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, "计划数据校验失败")
assert.Contains(t, resp.Message, "任务执行顺序重复")
})
t.Run("失败-仓库层业务错误", func(t *testing.T) {
// Arrange
mockRepo := &MockPlanRepository{
CreatePlanFunc: func(plan *models.Plan) error {
return repository.ErrNodeDoesNotExist // 模拟仓库层返回的业务错误
},
}
router, _ := setupTestRouter(mockRepo)
reqBody := CreatePlanRequest{
Name: "Plan with non-existent sub-plan",
ExecutionType: models.PlanExecutionTypeManual,
ContentType: models.PlanContentTypeSubPlans,
SubPlanIDs: []uint{999}, // 假设这个ID不存在
}
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, "/plans", 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, "创建计划失败: "+repository.ErrNodeDoesNotExist.Error(), resp.Message)
})
t.Run("失败-仓库层内部错误", func(t *testing.T) {
// Arrange
internalErr := errors.New("database connection lost")
mockRepo := &MockPlanRepository{
CreatePlanFunc: func(plan *models.Plan) error {
return internalErr // 模拟一个未知的内部错误
},
}
router, _ := setupTestRouter(mockRepo)
reqBody := CreatePlanRequest{
Name: "Test Plan",
ExecutionType: models.PlanExecutionTypeManual,
ContentType: models.PlanContentTypeTasks,
Tasks: []TaskRequest{},
}
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, "/plans", 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, "创建计划失败: "+internalErr.Error(), resp.Message)
})
}

View File

@@ -17,8 +17,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gorm.io/gorm"
)
@@ -77,13 +75,7 @@ func TestCreateUser(t *testing.T) {
gin.SetMode(gin.TestMode) // 设置 Gin 为测试模式
// 创建一个不输出日志的真实 logs.Logger 实例
discardSyncer := zapcore.AddSync(io.Discard)
encoderConfig := zap.NewProductionEncoderConfig()
encoder := zapcore.NewConsoleEncoder(encoderConfig)
core := zapcore.NewCore(encoder, discardSyncer, zap.DebugLevel) // 设置为 DebugLevel 以确保所有日志都被处理(并丢弃)
zapLogger := zap.New(core)
sugaredLogger := zapLogger.Sugar()
silentLogger := &logs.Logger{SugaredLogger: sugaredLogger}
silentLogger := logs.NewSilentLogger()
tests := []struct {
name string
@@ -248,13 +240,7 @@ func TestLogin(t *testing.T) {
gin.SetMode(gin.ReleaseMode)
// 创建一个不输出日志的真实 logs.Logger 实例
discardSyncer := zapcore.AddSync(io.Discard)
encoderConfig := zap.NewProductionEncoderConfig()
encoder := zapcore.NewConsoleEncoder(encoderConfig)
core := zapcore.NewCore(encoder, discardSyncer, zap.DebugLevel) // 设置为 DebugLevel 以确保所有日志都被处理(并丢弃)
zapLogger := zap.New(core)
sugaredLogger := zapLogger.Sugar()
silentLogger := &logs.Logger{SugaredLogger: sugaredLogger}
silentLogger := logs.NewSilentLogger()
tests := []struct {
name string

View File

@@ -6,6 +6,7 @@ package logs
import (
"context"
"fmt"
"io"
"os"
"strings"
"time"
@@ -163,3 +164,15 @@ func (g *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql
g.ZapLogger.With(fields...).Debugf("[GORM] trace")
// --- 逻辑修复结束 ---
}
// NewSilentLogger 创建一个不输出任何日志的 Logger 实例, 用于测试中屏蔽日志
func NewSilentLogger() *Logger {
// 创建一个不输出日志的真实 logs.Logger 实例
discardSyncer := zapcore.AddSync(io.Discard)
encoderConfig := zap.NewProductionEncoderConfig()
encoder := zapcore.NewConsoleEncoder(encoderConfig)
core := zapcore.NewCore(encoder, discardSyncer, zap.DebugLevel) // 设置为 DebugLevel 以确保所有日志都被处理(并丢弃)
zapLogger := zap.New(core)
sugaredLogger := zapLogger.Sugar()
return &Logger{SugaredLogger: sugaredLogger}
}