fix test helpers and make validation
This commit is contained in:
@@ -22,6 +22,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
|
||||
- **cache 写操作与计数器/原始 Redis helper 在 Redis 未初始化时返回 `ErrRedisNotReady`**:`Set` / `Delete` / `DeleteByPattern` / `Incr` / `IncrBy` / `Decr` / `GetTTL` / `SetExpire` / `GetRaw` / `SetRaw` 旧行为会静默返回成功或零值,调用方容易误判缓存写入、计数器更新或过期时间设置已经生效;现在统一显式返回错误。公共接口签名不变,但依赖“未启用 Redis 时当作成功”的下游需要改为忽略 `errors.Is(err, cache.ErrRedisNotReady)` 或显式启用 Redis。
|
||||
- **`cache.WithLock` / `cache.WithLockAutoExtend` 未获取到锁时返回 `ErrLockNotAcquired`**:旧行为返回 `nil` 并跳过业务函数,调用方无法区分“业务执行成功”和“根本没有执行”。同时锁 TTL 小于 1ms、续期/重试间隔非正会返回显式错误,避免 Redis PX=0 或 `time.NewTicker(0)` 崩溃。
|
||||
- **`test.Request.Execute()` 改为返回 `*test.Response`**:旧返回值是 `*httptest.ResponseRecorder`,与文档示例中的 `resp.AssertOK(t)` / `resp.ParseJSON(...)` 不一致;现在 `Execute()` 返回带断言和 JSON 解析方法的包装类型。需要原始 recorder 的调用方改用新增 `ExecuteRecorder()`。
|
||||
- **`jwt.ParseToken` 开始校验 issuer,`RefreshToken` 使用 `jwt.refresh_expire`**:签发者与当前配置不一致的 token 会被拒绝;刷新后的 token 过期时间优先使用 `refresh_expire`,未配置时回退 `expire`。`GenerateTokenWithCustomExpiry` 现在拒绝非正过期时间,`InvalidateTokenByID("")` 返回 `ErrEmptyJTI`。
|
||||
- **`App.Init()` 由 `sync.Once` 改为生命周期状态机**(app.go,M1):5 态 `stateCreated/Initializing/Initialized/Stopping/Stopped` + `lifecycleMu`(RWMutex) + `initMu`(Mutex)。`Shutdown` 后或 `Init` 失败后再调 `Init()` 返回新增导出错误 **`xlgo.ErrAppClosed`**(原 `sync.Once` "多次调用返回首次结果"语义不再适用——已关闭的 App 不可再 Init,需新建 App)。
|
||||
- **`App.Go()` 在 Shutdown 开始或 Init 失败后为 no-op**(app.go,M1):`state >= stateStopping` 时拒绝 `wg.Add` 直接返回,避免与 `Shutdown` 的 `wg.Wait` 竞争 `sync.WaitGroup` 契约(Add 须 happen-before Wait)。依赖"Shutdown 后仍可 Go"的下游需改用独立 goroutine。
|
||||
@@ -40,6 +41,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
- **M10 cache 剩余错误语义收口**:新增 `cache.ExistsE` 与可选 `CacheExistChecker`,让调用方能区分 key 不存在和 Redis/backend 故障;保留旧 `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option,`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。
|
||||
- **M11 SSE 换行注入修复**:`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。
|
||||
- **M15 utils/validation 资源与错误边界修复**:`HTTPClient.Upload` 改为流式 multipart 上传,不再把文件请求体完整缓存在内存中;`AppendFile` / `CopyFile` 返回写侧 `Close` 错误;`CheckPasswordAndUpgrade` 归一化非法 `targetCost`,避免异常配置触发超高 bcrypt cost;`ValidateStruct(nil)` 直接返回 nil。
|
||||
- **M16 测试工具与脚手架边界修复**:`MockDB` / `MockCache` / `MockStorage` 改为并发安全;`MockCache` 与 `MockStorage.UploadFromBytes` 复制字节切片,避免调用方修改污染内部状态;`MockStorage` 拒绝 nil 文件与超过 32MiB 的输入,避免测试 helper 被误用成无上限内存缓冲;`xlgo make` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码。
|
||||
- **M12 storage/compress 安全边界修复**:本地上传写侧 `Close` 错误会通过返回值暴露并清理残片;OSS `GetSignedURL` 统一经过 object key 净化;`UnzipWithOptions` 解析目标绝对路径失败时 fail-closed。
|
||||
|
||||
- **M13 cron handler panic 未 recover 崩进程**(cron/cron.go):`RunTask` 与 `checkAndRun` 调度 goroutine 统一经新增 `executeTask(t)` 边界 `recover`,panic 转为 error(含 `debug.Stack` 调用栈)记入 `task.LastError` 并向上返回,不再终止进程。外侧 `defer wg.Done()`/`running` 守卫释放不受影响(recover 在边界内完成)。顺带修复 `RunTask` 手动路径此前只更 `LastRun/RunCount`、不记 `LastError` 的子问题(现与调度路径一致)。
|
||||
|
||||
+27
-29
@@ -173,19 +173,19 @@ func validateProjectName(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidGoIdentifier 判断 s 是否为合法 Go 标识符(ASCII 范围)。
|
||||
// isValidGoIdentifier 判断 s 是否为脚手架接受的 ASCII 标识符:字母开头,后续允许字母、数字、下划线。
|
||||
func isValidGoIdentifier(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_'
|
||||
isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
isNum := r >= '0' && r <= '9'
|
||||
if i == 0 {
|
||||
if !isLetter {
|
||||
return false
|
||||
}
|
||||
} else if !isLetter && !isNum {
|
||||
} else if !isLetter && !isNum && r != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -207,17 +207,11 @@ func validateModulePath(module string) error {
|
||||
}
|
||||
|
||||
func makeFile(fileType, name string) error {
|
||||
// 文件名小写,但保留原分隔用于多词;标识符须为合法 Go 标识符(仅字母数字下划线)。
|
||||
// 将连字符/空格等转为下划线后再 Title,避免 "my-thing" → "My-Thing" 生成非法标识符(M20)。
|
||||
name = strings.ToLower(name)
|
||||
// P1 #21:拒绝含路径分隔符的名称,避免生成到目标目录之外。
|
||||
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return fmt.Errorf("名称不能包含路径分隔符或 ..: %q", name)
|
||||
if err := validateMakeName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
identBase := sanitizeIdent(name)
|
||||
caser := cases.Title(language.English)
|
||||
nameTitle := caser.String(strings.ReplaceAll(identBase, "_", " "))
|
||||
nameTitle = strings.ReplaceAll(nameTitle, " ", "") // 拼回 CamelCase
|
||||
name = strings.ToLower(name)
|
||||
nameTitle := makeNameTitle(name)
|
||||
|
||||
switch fileType {
|
||||
case "handler":
|
||||
@@ -233,24 +227,28 @@ func makeFile(fileType, name string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeIdent 把 name 中的非字母数字字符替换为下划线,生成合法 Go 标识符基串(M20)。
|
||||
// 如 "my-thing" → "my_thing",后续 Title 后得到 "MyThing"。
|
||||
func sanitizeIdent(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
} else if r >= 'A' && r <= 'Z' {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
// validateMakeName 校验 xlgo make 的资源名,避免路径穿越或生成不可编译的 Go 代码。
|
||||
func validateMakeName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("名称不能为空")
|
||||
}
|
||||
s := strings.Trim(b.String(), "_")
|
||||
if s == "" {
|
||||
return "xlgo"
|
||||
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return fmt.Errorf("名称不能包含路径分隔符或 ..: %q", name)
|
||||
}
|
||||
return s
|
||||
if strings.HasPrefix(name, ".") {
|
||||
return fmt.Errorf("名称不能以 . 开头: %q", name)
|
||||
}
|
||||
if !isValidGoIdentifier(name) {
|
||||
return fmt.Errorf("名称 %q 必须是合法标识符:须以字母开头,仅含字母、数字、下划线", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeNameTitle 把 snake_case 名称转为导出的 CamelCase 类型名。
|
||||
func makeNameTitle(name string) string {
|
||||
caser := cases.Title(language.English)
|
||||
nameTitle := caser.String(strings.ReplaceAll(name, "_", " "))
|
||||
return strings.ReplaceAll(nameTitle, " ", "")
|
||||
}
|
||||
|
||||
func createHandler(name, nameTitle string) error {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateMakeNameRejectsUnsafeOrInvalidNames(t *testing.T) {
|
||||
tests := []string{
|
||||
"",
|
||||
"../user",
|
||||
`..\user`,
|
||||
".user",
|
||||
"my-thing",
|
||||
"123user",
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt, func(t *testing.T) {
|
||||
if err := validateMakeName(tt); err == nil {
|
||||
t.Fatalf("名称 %q 应返回错误", tt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeFileAcceptsSnakeCaseIdentifier(t *testing.T) {
|
||||
oldWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("获取当前目录失败: %v", err)
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
t.Fatalf("切换测试目录失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chdir(oldWd); err != nil {
|
||||
t.Fatalf("恢复测试目录失败: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err := makeFile("model", "user_profile"); err != nil {
|
||||
t.Fatalf("生成模型失败: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(tmp, "model", "user_profile.go")
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("读取生成文件失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "type UserProfile struct") {
|
||||
t.Fatalf("生成类型名不符合预期:\n%s", content)
|
||||
}
|
||||
}
|
||||
+114
-77
@@ -3,25 +3,27 @@ package test
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetupRouter 创建测试路由。
|
||||
// SetupRouter 创建不带框架默认中间件的测试路由。
|
||||
//
|
||||
// 刻意返回裸 gin.New()(N3 文档化):不含框架中间件(RequestID/Recover/超时等),
|
||||
// 让测试方完全控制中间件链。需要框架中间件的集成测试应直接构造 xlgo.App 或手动 Use。
|
||||
// 需要 RequestID、Recover、超时等框架中间件的集成测试,应直接构造 xlgo.App
|
||||
// 或手动 Use 对应中间件。
|
||||
func SetupRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
return gin.New()
|
||||
}
|
||||
|
||||
// Request 测试请求辅助函数
|
||||
// Request 是测试请求构造器。
|
||||
type Request struct {
|
||||
router *gin.Engine
|
||||
method string
|
||||
@@ -30,7 +32,7 @@ type Request struct {
|
||||
headers map[string]string
|
||||
}
|
||||
|
||||
// NewRequest 创建测试请求
|
||||
// NewRequest 创建测试请求。
|
||||
func NewRequest(router *gin.Engine, method, path string) *Request {
|
||||
return &Request{
|
||||
router: router,
|
||||
@@ -40,90 +42,92 @@ func NewRequest(router *gin.Engine, method, path string) *Request {
|
||||
}
|
||||
}
|
||||
|
||||
// GET 创建 GET 请求
|
||||
// GET 创建 GET 请求。
|
||||
func GET(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodGet, path)
|
||||
}
|
||||
|
||||
// POST 创建 POST 请求
|
||||
// POST 创建 POST 请求。
|
||||
func POST(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodPost, path)
|
||||
}
|
||||
|
||||
// PUT 创建 PUT 请求
|
||||
// PUT 创建 PUT 请求。
|
||||
func PUT(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodPut, path)
|
||||
}
|
||||
|
||||
// DELETE 创建 DELETE 请求
|
||||
// DELETE 创建 DELETE 请求。
|
||||
func DELETE(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodDelete, path)
|
||||
}
|
||||
|
||||
// PATCH 创建 PATCH 请求
|
||||
// PATCH 创建 PATCH 请求。
|
||||
func PATCH(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodPatch, path)
|
||||
}
|
||||
|
||||
// WithBody 设置请求体
|
||||
// WithBody 设置请求体。
|
||||
func (r *Request) WithBody(body any) *Request {
|
||||
r.body = body
|
||||
return r
|
||||
}
|
||||
|
||||
// WithJSON 设置 JSON 请求体
|
||||
// WithJSON 设置 JSON 请求体并写入 Content-Type。
|
||||
func (r *Request) WithJSON(body any) *Request {
|
||||
r.body = body
|
||||
r.headers["Content-Type"] = "application/json"
|
||||
return r
|
||||
}
|
||||
|
||||
// WithHeader 设置请求头
|
||||
// WithHeader 设置请求头。
|
||||
func (r *Request) WithHeader(key, value string) *Request {
|
||||
r.headers[key] = value
|
||||
return r
|
||||
}
|
||||
|
||||
// WithToken 设置 Authorization 头
|
||||
// WithToken 设置 Authorization Bearer token。
|
||||
func (r *Request) WithToken(token string) *Request {
|
||||
r.headers["Authorization"] = "Bearer " + token
|
||||
return r
|
||||
}
|
||||
|
||||
// Execute 执行请求
|
||||
func (r *Request) Execute() *httptest.ResponseRecorder {
|
||||
var bodyReader *bytes.Reader
|
||||
// Execute 执行请求并返回带断言方法的响应包装。
|
||||
func (r *Request) Execute() *Response {
|
||||
return &Response{ResponseRecorder: r.ExecuteRecorder()}
|
||||
}
|
||||
|
||||
// ExecuteRecorder 执行请求并返回原始 httptest.ResponseRecorder。
|
||||
func (r *Request) ExecuteRecorder() *httptest.ResponseRecorder {
|
||||
var bodyReader *bytes.Reader
|
||||
if r.body != nil {
|
||||
bodyBytes, _ := json.Marshal(r.body)
|
||||
bodyReader = bytes.NewReader(bodyBytes)
|
||||
} else {
|
||||
bodyReader = bytes.NewReader([]byte{})
|
||||
bodyReader = bytes.NewReader(nil)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(r.method, r.path, bodyReader)
|
||||
|
||||
for key, value := range r.headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
r.router.ServeHTTP(recorder, req)
|
||||
|
||||
return recorder
|
||||
}
|
||||
|
||||
// Response 响应解析辅助
|
||||
// Response 是测试响应辅助包装。
|
||||
type Response struct {
|
||||
*httptest.ResponseRecorder
|
||||
}
|
||||
|
||||
// ParseJSON 解析 JSON 响应
|
||||
// ParseJSON 解析 JSON 响应。
|
||||
func (r *Response) ParseJSON(v any) error {
|
||||
return json.Unmarshal(r.Body.Bytes(), v)
|
||||
}
|
||||
|
||||
// AssertStatus 断言状态码
|
||||
// AssertStatus 断言 HTTP 状态码。
|
||||
func (r *Response) AssertStatus(t *testing.T, expected int) {
|
||||
t.Helper()
|
||||
if r.Code != expected {
|
||||
@@ -131,43 +135,43 @@ func (r *Response) AssertStatus(t *testing.T, expected int) {
|
||||
}
|
||||
}
|
||||
|
||||
// AssertOK 断言状态码为 200
|
||||
// AssertOK 断言状态码为 200。
|
||||
func (r *Response) AssertOK(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusOK)
|
||||
}
|
||||
|
||||
// AssertCreated 断言状态码为 201
|
||||
// AssertCreated 断言状态码为 201。
|
||||
func (r *Response) AssertCreated(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusCreated)
|
||||
}
|
||||
|
||||
// AssertBadRequest 断言状态码为 400
|
||||
// AssertBadRequest 断言状态码为 400。
|
||||
func (r *Response) AssertBadRequest(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// AssertUnauthorized 断言状态码为 401
|
||||
// AssertUnauthorized 断言状态码为 401。
|
||||
func (r *Response) AssertUnauthorized(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// AssertForbidden 断言状态码为 403
|
||||
// AssertForbidden 断言状态码为 403。
|
||||
func (r *Response) AssertForbidden(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusForbidden)
|
||||
}
|
||||
|
||||
// AssertNotFound 断言状态码为 404
|
||||
// AssertNotFound 断言状态码为 404。
|
||||
func (r *Response) AssertNotFound(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// AssertJSONContains 断言 JSON 包含指定字段
|
||||
// AssertJSONContains 断言 JSON 响应包含指定字段和值,支持 data.user.id 形式的嵌套键。
|
||||
func (r *Response) AssertJSONContains(t *testing.T, key string, expected any) {
|
||||
t.Helper()
|
||||
|
||||
@@ -177,10 +181,8 @@ func (r *Response) AssertJSONContains(t *testing.T, key string, expected any) {
|
||||
return
|
||||
}
|
||||
|
||||
// 支持嵌套键(如 "data.user.id")
|
||||
keys := splitKey(key)
|
||||
current := result
|
||||
|
||||
for i, k := range keys {
|
||||
if i == len(keys)-1 {
|
||||
if current[k] != expected {
|
||||
@@ -211,92 +213,107 @@ func splitKey(key string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// Mock 模拟工具
|
||||
|
||||
// MockDB 模拟数据库(用于简单测试)
|
||||
// MockDB 是并发安全的简单内存数据库 mock。
|
||||
type MockDB struct {
|
||||
mu sync.RWMutex
|
||||
data map[any]any
|
||||
}
|
||||
|
||||
// NewMockDB 创建模拟数据库
|
||||
// NewMockDB 创建模拟数据库。
|
||||
func NewMockDB() *MockDB {
|
||||
return &MockDB{
|
||||
data: make(map[any]any),
|
||||
}
|
||||
return &MockDB{data: make(map[any]any)}
|
||||
}
|
||||
|
||||
// Set 设置数据
|
||||
// Set 设置数据。
|
||||
func (m *MockDB) Set(key, value any) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data[key] = value
|
||||
}
|
||||
|
||||
// Get 获取数据
|
||||
// Get 获取数据。
|
||||
func (m *MockDB) Get(key any) (any, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
v, ok := m.data[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Delete 删除数据
|
||||
// Delete 删除数据。
|
||||
func (m *MockDB) Delete(key any) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.data, key)
|
||||
}
|
||||
|
||||
// Clear 清空数据
|
||||
// Clear 清空数据。
|
||||
func (m *MockDB) Clear() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data = make(map[any]any)
|
||||
}
|
||||
|
||||
// MockCache 模拟缓存
|
||||
// MockCache 是并发安全的简单内存缓存 mock。
|
||||
type MockCache struct {
|
||||
mu sync.RWMutex
|
||||
data map[string][]byte
|
||||
}
|
||||
|
||||
// NewMockCache 创建模拟缓存
|
||||
// NewMockCache 创建模拟缓存。
|
||||
func NewMockCache() *MockCache {
|
||||
return &MockCache{
|
||||
data: make(map[string][]byte),
|
||||
}
|
||||
return &MockCache{data: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
// Set 设置缓存
|
||||
// Set 设置缓存,内部会复制 value,避免调用方后续修改污染 mock。
|
||||
func (m *MockCache) Set(key string, value []byte) {
|
||||
m.data[key] = value
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data[key] = append([]byte(nil), value...)
|
||||
}
|
||||
|
||||
// Get 获取缓存
|
||||
// Get 获取缓存,返回值会复制一份,避免调用方修改内部状态。
|
||||
func (m *MockCache) Get(key string) ([]byte, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
v, ok := m.data[key]
|
||||
return v, ok
|
||||
return append([]byte(nil), v...), ok
|
||||
}
|
||||
|
||||
// Delete 删除缓存
|
||||
// Delete 删除缓存。
|
||||
func (m *MockCache) Delete(key string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.data, key)
|
||||
}
|
||||
|
||||
// Exists 检查缓存是否存在
|
||||
// Exists 检查缓存是否存在。
|
||||
func (m *MockCache) Exists(key string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
_, ok := m.data[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Clear 清空缓存
|
||||
// Clear 清空缓存。
|
||||
func (m *MockCache) Clear() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data = make(map[string][]byte)
|
||||
}
|
||||
|
||||
// MockStorage 模拟存储。
|
||||
//
|
||||
// 注意(N3):本 mock 与真实 storage 的签名已对齐——
|
||||
// - Upload(file *multipart.FileHeader, subdir string) 对应 storage.Upload
|
||||
// - UploadFromBytes(data []byte, filename, subdir string) 对应 storage.UploadFromBytes
|
||||
// 真实 storage 无导出接口,此 mock 仅供测试手写注入,不强制实现接口。
|
||||
// ErrMockStorageTooLarge 表示 mock storage 收到超过内存保护上限的内容。
|
||||
var ErrMockStorageTooLarge = errors.New("mock storage: 文件超过大小限制")
|
||||
|
||||
const mockStorageMaxBytes int64 = 32 << 20
|
||||
|
||||
// MockStorage 是并发安全的简单内存存储 mock。
|
||||
type MockStorage struct {
|
||||
mu sync.RWMutex
|
||||
files map[string][]byte
|
||||
urls map[string]string
|
||||
}
|
||||
|
||||
// NewMockStorage 创建模拟存储
|
||||
// NewMockStorage 创建模拟存储。
|
||||
func NewMockStorage() *MockStorage {
|
||||
return &MockStorage{
|
||||
files: make(map[string][]byte),
|
||||
@@ -304,35 +321,55 @@ func NewMockStorage() *MockStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// Upload 模拟上传(签名对齐 storage.Upload)。
|
||||
// Upload 模拟上传,签名对齐 storage.Upload。
|
||||
func (m *MockStorage) Upload(file *multipart.FileHeader, subdir string) (string, error) {
|
||||
if file == nil {
|
||||
return "", errors.New("mock storage: 文件不能为空")
|
||||
}
|
||||
if file.Size > mockStorageMaxBytes {
|
||||
return "", ErrMockStorageTooLarge
|
||||
}
|
||||
|
||||
data, err := file.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer data.Close()
|
||||
b, err := io.ReadAll(data)
|
||||
b, err := io.ReadAll(io.LimitReader(data, mockStorageMaxBytes+1))
|
||||
if cerr := data.Close(); cerr != nil {
|
||||
err = errors.Join(err, cerr)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if int64(len(b)) > mockStorageMaxBytes {
|
||||
return "", ErrMockStorageTooLarge
|
||||
}
|
||||
|
||||
path := "/mock/" + subdir + "/" + file.Filename
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.files[path] = b
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// UploadFromBytes 模拟从字节上传(签名对齐 storage.UploadFromBytes)。
|
||||
// UploadFromBytes 模拟字节上传,签名对齐 storage.UploadFromBytes。
|
||||
func (m *MockStorage) UploadFromBytes(data []byte, filename, subdir string) (string, error) {
|
||||
if int64(len(data)) > mockStorageMaxBytes {
|
||||
return "", ErrMockStorageTooLarge
|
||||
}
|
||||
path := "/mock/" + subdir + "/" + filename
|
||||
m.files[path] = data
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.files[path] = append([]byte(nil), data...)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// GetURL 获取 URL
|
||||
// GetURL 获取 mock URL。
|
||||
func (m *MockStorage) GetURL(path string) string {
|
||||
return "http://mock.test" + path
|
||||
}
|
||||
|
||||
// AssertEqual 通用相等断言
|
||||
// AssertEqual 断言相等。
|
||||
func AssertEqual(t *testing.T, expected, actual any) {
|
||||
t.Helper()
|
||||
if expected != actual {
|
||||
@@ -340,7 +377,7 @@ func AssertEqual(t *testing.T, expected, actual any) {
|
||||
}
|
||||
}
|
||||
|
||||
// AssertNotNil 断言不为空
|
||||
// AssertNotNil 断言非空。
|
||||
func AssertNotNil(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
if value == nil {
|
||||
@@ -348,7 +385,7 @@ func AssertNotNil(t *testing.T, value any) {
|
||||
}
|
||||
}
|
||||
|
||||
// AssertNil 断言为空
|
||||
// AssertNil 断言为空。
|
||||
func AssertNil(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
if value != nil {
|
||||
@@ -356,23 +393,23 @@ func AssertNil(t *testing.T, value any) {
|
||||
}
|
||||
}
|
||||
|
||||
// AssertTrue 断言为真
|
||||
// AssertTrue 断言为 true。
|
||||
func AssertTrue(t *testing.T, value bool) {
|
||||
t.Helper()
|
||||
if !value {
|
||||
t.Error("期望为真,实际为假")
|
||||
t.Error("期望为 true,实际为 false")
|
||||
}
|
||||
}
|
||||
|
||||
// AssertFalse 断言为假
|
||||
// AssertFalse 断言为 false。
|
||||
func AssertFalse(t *testing.T, value bool) {
|
||||
t.Helper()
|
||||
if value {
|
||||
t.Error("期望为假,实际为真")
|
||||
t.Error("期望为 false,实际为 true")
|
||||
}
|
||||
}
|
||||
|
||||
// AssertError 断言有错误
|
||||
// AssertError 断言有错误。
|
||||
func AssertError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
@@ -380,7 +417,7 @@ func AssertError(t *testing.T, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// AssertNoError 断言无错误
|
||||
// AssertNoError 断言无错误。
|
||||
func AssertNoError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRequestExecuteReturnsResponse(t *testing.T) {
|
||||
router := SetupRouter()
|
||||
router.POST("/users", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"name": "张三"}})
|
||||
})
|
||||
|
||||
resp := POST(router, "/users").WithJSON(gin.H{"name": "张三"}).Execute()
|
||||
resp.AssertOK(t)
|
||||
resp.AssertJSONContains(t, "data.name", "张三")
|
||||
}
|
||||
|
||||
func TestRequestExecuteRecorderKeepsRawRecorder(t *testing.T) {
|
||||
router := SetupRouter()
|
||||
router.GET("/ping", func(c *gin.Context) {
|
||||
c.String(http.StatusCreated, "pong")
|
||||
})
|
||||
|
||||
recorder := GET(router, "/ping").ExecuteRecorder()
|
||||
if _, ok := any(recorder).(*httptest.ResponseRecorder); !ok {
|
||||
t.Fatalf("ExecuteRecorder 应返回原始 ResponseRecorder")
|
||||
}
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("状态码错误: 期望 %d, 实际 %d", http.StatusCreated, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockCacheCopiesBytes(t *testing.T) {
|
||||
cache := NewMockCache()
|
||||
input := []byte("abc")
|
||||
cache.Set("k", input)
|
||||
input[0] = 'x'
|
||||
|
||||
got, ok := cache.Get("k")
|
||||
if !ok {
|
||||
t.Fatalf("缓存不存在")
|
||||
}
|
||||
if string(got) != "abc" {
|
||||
t.Fatalf("缓存被外部输入污染: %q", got)
|
||||
}
|
||||
|
||||
got[0] = 'y'
|
||||
gotAgain, _ := cache.Get("k")
|
||||
if string(gotAgain) != "abc" {
|
||||
t.Fatalf("缓存被返回值污染: %q", gotAgain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMocksConcurrentAccess(t *testing.T) {
|
||||
db := NewMockDB()
|
||||
cache := NewMockCache()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 64; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
db.Set(i, i)
|
||||
_, _ = db.Get(i)
|
||||
cache.Set("k", []byte{byte(i)})
|
||||
_, _ = cache.Get("k")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestMockStorageRejectsNilAndLargeInput(t *testing.T) {
|
||||
storage := NewMockStorage()
|
||||
if _, err := storage.Upload(nil, "avatars"); err == nil {
|
||||
t.Fatalf("nil 文件应返回错误")
|
||||
}
|
||||
|
||||
large := bytes.Repeat([]byte{'x'}, int(mockStorageMaxBytes)+1)
|
||||
if _, err := storage.UploadFromBytes(large, "large.bin", "files"); !errors.Is(err, ErrMockStorageTooLarge) {
|
||||
t.Fatalf("超大文件错误不符合预期: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user