package testutil import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "sync" "testing" "time" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "gorm.io/gorm" "wails-app/internal/core" "wails-app/internal/dao" "wails-app/internal/global" "wails-app/internal/initialize" "wails-app/internal/model/common/response" "wails-app/internal/model/system/request" "wails-app/internal/service/system" "wails-app/internal/utils" ) // TestEnv 测试环境:DB + Router + HTTP Client + Token type TestEnv struct { DB *gorm.DB Router *gin.Engine Client *http.Client Token string UserID uint BaseURL string } var ( mu sync.Mutex testEnv *TestEnv ) // InitTestEnv 初始化测试环境(所有测试共享一个环境) func InitTestEnv(t *testing.T) *TestEnv { t.Helper() mu.Lock() defer mu.Unlock() if testEnv != nil { return testEnv } // 1. 初始化 Viper 配置和 Zap Logger configPath := "D:\\lq\\Smart Parking\\lc_garage\\config.yaml" core.Viper(configPath) global.GVA_LOG = core.Zap() // 2. 初始化 BlackCache(JWT 中间件依赖) initialize.OtherInit() // 3. 创建独立 SQLite 数据库 tmpDB := fmt.Sprintf("test_%d_%d.db", time.Now().UnixNano(), os.Getpid()) db, err := gorm.Open(sqlite.Open(tmpDB), &gorm.Config{}) if err != nil { t.Fatalf("打开测试数据库失败: %v", err) } global.GVA_DB = db // 4. 建表 + 种子数据 initialize.RegisterTables() initialize.SeedSystemData() system.LoadAll() // 5. 添加 Casbin 权限规则(必须在 RegisterTables + SeedSystemData 之后) addTestCasbinRules(t, db) // 6. 创建测试基础设施 createTestInfrastructure(t, db) // 7. 注册路由 gin.SetMode(gin.TestMode) router := initialize.Routers() // 8. 生成 admin JWT Token token, userID := generateAdminToken(t, db) testEnv = &TestEnv{ DB: db, Router: router, Client: &http.Client{Timeout: 15 * time.Second}, BaseURL: "http://127.0.0.1:8888", Token: token, UserID: userID, } return testEnv } // createTestInfrastructure 创建测试基础设施 func createTestInfrastructure(t *testing.T, db *gorm.DB) { t.Helper() var lotCount int64 db.Model(&dao.ParkingLot{}).Count(&lotCount) if lotCount > 0 { return } lot := dao.ParkingLot{ LotCode: "TEST001", LotName: "测试停车场-A区", Capacity: 100, Available: 100, Description: "自动化测试停车场", } db.Create(&lot) booth := dao.Booth{ BoothCode: "B001", BoothName: "测试岗亭1号", ParkingLotID: lot.ID, } db.Create(&booth) channelIn := dao.Channel{ ChannelCode: "CH-IN-001", ChannelName: "入口通道1", Direction: "in", ParkingLotID: lot.ID, BoothID: booth.ID, AllowTemporary: true, } db.Create(&channelIn) channelOut := dao.Channel{ ChannelCode: "CH-OUT-001", ChannelName: "出口通道1", Direction: "out", ParkingLotID: lot.ID, BoothID: booth.ID, AllowTemporary: true, } db.Create(&channelOut) reader := dao.UHFReader{ DeviceCode: "UHF001", DeviceName: "测试UHF读卡器", DeviceType: "TCP", ConnectType: "tcp", IPAddress: "127.0.0.1", Port: 8000, IsActive: true, ParkingLotID: lot.ID, ChannelID: channelIn.ID, } db.Create(&reader) var tempType dao.VehicleType db.Where("is_system = ?", true).First(&tempType) feeConfig := dao.FeeConfig{ VehicleTypeID: tempType.ID, StartFee: 0, StartTime: 30, UnitTime: 60, UnitFee: 5, DailyMaxFee: 50, IsVIPFree: false, VIPDiscount: 0.8, } db.Create(&feeConfig) var normalType dao.VehicleType db.Where("is_system = ? AND name != ?", true, "临时车").First(&normalType) if normalType.ID == 0 { normalType = dao.VehicleType{Name: "月租车", Remarks: "测试月租车类型"} db.Create(&normalType) } feeConfig2 := dao.FeeConfig{ VehicleTypeID: normalType.ID, StartFee: 0, StartTime: 0, UnitTime: 60, UnitFee: 3, DailyMaxFee: 30, IsVIPFree: true, VIPDiscount: 0.5, } db.Create(&feeConfig2) } // addTestCasbinRules 为 admin(888) 添加测试所需的 Casbin 路由权限 // 注意:实际注册的路由不带 /v1 前缀(config.yaml 中 router-prefix 为空) func addTestCasbinRules(t *testing.T, db *gorm.DB) { t.Helper() rules := []struct { v0, v1, v2 string }{ // 系统管理 {"888", "/user/getUserList", "POST"}, // 停车场管理 {"888", "/parking/lot/create", "POST"}, {"888", "/parking/lot/list", "GET"}, {"888", "/parking/lot/get", "GET"}, {"888", "/parking/lot/update", "PUT"}, {"888", "/parking/lot/delete", "DELETE"}, // 岗亭管理 {"888", "/parking/booth/create", "POST"}, {"888", "/parking/booth/list", "GET"}, // 通道管理 {"888", "/parking/channel/create", "POST"}, {"888", "/parking/channel/list", "GET"}, // 设备管理 {"888", "/parking/device/create", "POST"}, {"888", "/parking/device/list", "GET"}, // 车辆管理 {"888", "/vehicle/create", "POST"}, {"888", "/vehicle/get-by-plate", "GET"}, {"888", "/vehicle/entry", "POST"}, {"888", "/vehicle/exit/preview", "POST"}, {"888", "/vehicle/exit/confirm", "POST"}, // 收费配置 {"888", "/vehicle/fee-config/list", "GET"}, // 收费记录 {"888", "/payment/list", "GET"}, // 黑白名单 {"888", "/shortlist/createShortlist", "POST"}, {"888", "/shortlist/queryAllShortlists", "GET"}, // 黑白名单查询/更新/删除 {"888", "/shortlist/queryShortlistList", "POST"}, {"888", "/shortlist/updateShortlist", "PUT"}, {"888", "/shortlist/deleteShortlist", "DELETE"}, // 车辆类型管理 {"888", "/vehicle/type/create", "POST"}, {"888", "/vehicle/type/all", "GET"}, // 车主管理 {"888", "/owner/create", "POST"}, {"888", "/owner/list", "GET"}, {"888", "/vehicle/update", "PUT"}, {"888", "/vehicle/delete", "DELETE"}, {"888", "/vehicle/list", "GET"}, {"888", "/vehicle/all", "GET"}, {"888", "/vehicle/type/update", "PUT"}, {"888", "/vehicle/type/delete", "DELETE"}, {"888", "/vehicle/type/list", "POST"}, {"888", "/vehicle/fee-config/create", "POST"}, {"888", "/vehicle/fee-config/update", "PUT"}, {"888", "/vehicle/fee-config/delete", "DELETE"}, {"888", "/vehicle/fee-config/get", "GET"}, {"888", "/vehicle/record/get", "GET"}, {"888", "/vehicle/record/list-by-plate", "GET"}, {"888", "/vehicle/record/list", "POST"}, {"888", "/owner/update", "PUT"}, {"888", "/owner/delete", "DELETE"}, {"888", "/owner/get-by-phone", "GET"}, {"888", "/owner/get", "GET"}, {"888", "/parking/lot/all", "GET"}, {"888", "/parking/booth/update", "PUT"}, {"888", "/parking/channel/update", "PUT"}, {"888", "/parking/device/update", "PUT"}, {"888", "/monthly-card/create", "POST"}, {"888", "/monthly-card/renew", "POST"}, {"888", "/monthly-card/refund", "DELETE"}, {"888", "/monthly-card/list", "GET"}, // 数字票 {"888", "/digital-ticket/summary", "GET"}, {"888", "/digital-ticket/list", "GET"}, {"888", "/digital-ticket/*", "GET"}, {"888", "/digital-ticket/*/pay", "POST"}, {"888", "/digital-ticket/*/exit", "POST"}, } for _, r := range rules { var count int64 db.Raw("SELECT COUNT(*) FROM casbin_rule WHERE ptype=? AND v0=? AND v1=? AND v2=?", "p", r.v0, r.v1, r.v2).Scan(&count) if count == 0 { db.Exec("INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES (?, ?, ?, ?, '', '', '')", "p", r.v0, r.v1, r.v2) } } // 重新加载 Casbin 策略缓存(因为刚插入了新规则) system.CasbinServiceApp.FreshCasbin() } // generateAdminToken 直接生成 JWT Token(绕过验证码) func generateAdminToken(t *testing.T, db *gorm.DB) (string, uint) { t.Helper() var user dao.SysUser if err := db.Where("username = ?", "admin").First(&user).Error; err != nil { t.Fatalf("查询 admin 用户失败: %v", err) } j := &utils.JWT{SigningKey: []byte(global.GVA_CONFIG.JWT.SigningKey)} claims := j.CreateClaims(request.BaseClaims{ UUID: user.UUID, ID: user.ID, NickName: user.NickName, Username: user.Username, AuthorityId: user.AuthorityId, }) token, err := j.CreateToken(claims) if err != nil { t.Fatalf("生成 Token 失败: %v", err) } return token, user.ID } // doRaw 发送原始 HTTP 请求 func (e *TestEnv) doRaw(method, path string, body interface{}) (*http.Response, []byte) { var reqBody io.Reader = bytes.NewReader([]byte{}) if body != nil { data, _ := json.Marshal(body) reqBody = bytes.NewReader(data) } req, err := http.NewRequest(method, e.BaseURL+path, reqBody) if err != nil { panic(fmt.Sprintf("创建请求失败: %v", err)) } req.Header.Set("Content-Type", "application/json") req.Header.Set("x-token", e.Token) w := httptest.NewRecorder() e.Router.ServeHTTP(w, req) resp := &http.Response{ StatusCode: w.Code, Header: w.Header(), Body: io.NopCloser(bytes.NewReader(w.Body.Bytes())), } return resp, w.Body.Bytes() } // DoGet GET 请求 func (e *TestEnv) DoGet(path string, params map[string]string) (*http.Response, []byte) { url := path if len(params) > 0 { query := "" for k, v := range params { if query != "" { query += "&" } query += fmt.Sprintf("%s=%s", k, v) } url += "?" + query } return e.doRaw("GET", url, nil) } // DoPost POST 请求 func (e *TestEnv) DoPost(path string, body interface{}) (*http.Response, []byte) { return e.doRaw("POST", path, body) } // DoPut PUT 请求 func (e *TestEnv) DoPut(path string, body interface{}) (*http.Response, []byte) { return e.doRaw("PUT", path, body) } // DoDelete DELETE 请求 func (e *TestEnv) DoDelete(path string) (*http.Response, []byte) { return e.doRaw("DELETE", path, nil) } // DoGetRaw GET 请求(不带 Token,用于公共接口) func (e *TestEnv) DoGetRaw(path string, params map[string]string) (*http.Response, []byte) { url := e.BaseURL + path if len(params) > 0 { query := "" for k, v := range params { if query != "" { query += "&" } query += fmt.Sprintf("%s=%s", k, v) } url += "?" + query } req, err := http.NewRequest("GET", url, nil) if err != nil { panic(fmt.Sprintf("创建请求失败: %v", err)) } w := httptest.NewRecorder() e.Router.ServeHTTP(w, req) resp := &http.Response{ StatusCode: w.Code, Header: w.Header(), Body: io.NopCloser(bytes.NewReader(w.Body.Bytes())), } return resp, w.Body.Bytes() } // AssertResponse 断言响应状态码和 code func AssertResponse(t *testing.T, resp *http.Response, body []byte, expectedStatus, expectCode int) { t.Helper() if resp.StatusCode != expectedStatus { t.Errorf("HTTP 状态码不匹配: 期望 %d, 实际 %d | Body: %s", expectedStatus, resp.StatusCode, truncate(string(body), 300)) } var result response.Response if err := json.Unmarshal(body, &result); err != nil { t.Fatalf("JSON 解析失败: %v | Body: %s", err, truncate(string(body), 300)) } if result.Code != expectCode { t.Errorf("code 不匹配: 期望 %d, 实际 %d | Msg: %s", expectCode, result.Code, result.Msg) } } // ParseDataMap 解析响应 data 为 map func ParseDataMap(body []byte) map[string]interface{} { var result response.Response json.Unmarshal(body, &result) m, _ := result.Data.(map[string]interface{}) return m } // ParseDataList 解析响应 data 为列表 func ParseDataList(body []byte) []interface{} { var result response.Response json.Unmarshal(body, &result) list, _ := result.Data.([]interface{}) return list } // ParsePageList 解析分页响应 data.list(如 {"total":N,"list":[...]}) func ParsePageList(body []byte) []interface{} { var result response.Response json.Unmarshal(body, &result) m, ok := result.Data.(map[string]interface{}) if !ok { return nil } rawList, exists := m["list"] if !exists { return nil } list, ok := rawList.([]interface{}) if !ok { return nil } return list } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." } // mapKeys returns the keys of a map for diagnostic purposes func mapKeys(m map[string]interface{}) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys } // ParsePageList 解析分页响应 data.list