diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d0b490..c099132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 的子问题(现与调度路径一致)。 diff --git a/cmd/xlgo/commands.go b/cmd/xlgo/commands.go index aec44f9..af12bf2 100644 --- a/cmd/xlgo/commands.go +++ b/cmd/xlgo/commands.go @@ -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 { diff --git a/cmd/xlgo/commands_test.go b/cmd/xlgo/commands_test.go new file mode 100644 index 0000000..3f66992 --- /dev/null +++ b/cmd/xlgo/commands_test.go @@ -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) + } +} diff --git a/test/test.go b/test/test.go index 9f61e1e..1558a0a 100644 --- a/test/test.go +++ b/test/test.go @@ -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 { diff --git a/test/test_m16_test.go b/test/test_m16_test.go new file mode 100644 index 0000000..44216b7 --- /dev/null +++ b/test/test_m16_test.go @@ -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) + } +}