fix test helpers and make validation

This commit is contained in:
杭州明婳科技
2026-07-08 18:21:32 +08:00
parent 667f0793fc
commit 0610a159f0
5 changed files with 288 additions and 106 deletions
+2
View File
@@ -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.goM1):`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
View File
@@ -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 {
+56
View File
@@ -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
View File
@@ -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。
// 需要 RequestIDRecover超时等框架中间件的集成测试,应直接构造 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 {
+89
View File
@@ -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)
}
}