package handlers_test import ( "bytes" "encoding/json" "fmt" "net/http/httptest" "os" "path/filepath" "testing" "time" "github.com/gin-gonic/gin" "booklib/cmd/webui/api" "booklib/internal/auth" "booklib/internal/config" "booklib/internal/ports/portsfake" ) // testEnv 是纯 fake 装配(Task 27):无 PG/Redis。请求走 api.NewRouter 组装的 // 真实 engine,因此认证/鉴权中间件、路由匹配与 gin 的 header 落盘行为都被覆盖; // 集成测试(auth_test.go 的 setupAPI)另走真库,两层互补。 type testEnv struct { cfg *config.Config router *gin.Engine users *portsfake.Users libs *portsfake.Libraries books *portsfake.Books progress *portsfake.Progress bookmarks *portsfake.Bookmarks rl *portsfake.RateLimiter sc *portsfake.Scanner med *portsfake.Media up *portsfake.Uploads booksDir string } func newTestEnv(t *testing.T) *testEnv { t.Helper() gin.SetMode(gin.TestMode) resolved, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal(err) } cfg := &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), UploadMaxMB: 1, ScanInterval: time.Minute, BooksDir: resolved, CacheDir: t.TempDir(), TrustedProxies: []string{"172.16.0.0/12"}} users := portsfake.NewUsers() libs := portsfake.NewLibraries() books := portsfake.NewBooks() progress := portsfake.NewProgress(libs, books) e := &testEnv{ cfg: cfg, users: users, libs: libs, books: books, progress: progress, bookmarks: portsfake.NewBookmarks(), rl: portsfake.NewRateLimiter(), sc: portsfake.NewScanner(), med: portsfake.NewMedia(), up: portsfake.NewUploads(), booksDir: resolved, } e.router = api.NewRouter(cfg, users, libs, books, progress, e.bookmarks, e.rl, e.sc, e.med, e.up) return e } // token 签发一个带角色的 JWT(uid 固定 1,与 fake 里 seed 的用户对应)。 func (e *testEnv) token(t *testing.T, role string, uid int64) string { t.Helper() tok, err := auth.Sign(e.cfg.JWTSecret, uid, role) if err != nil { t.Fatal(err) } return tok } // do 以 Bearer token 走完整 engine;body 非 nil 时按 JSON 发送。 func (e *testEnv) do(t *testing.T, method, path, tok string, body any) *httptest.ResponseRecorder { t.Helper() var reader *bytes.Reader if body != nil { b, err := json.Marshal(body) if err != nil { t.Fatal(err) } reader = bytes.NewReader(b) } else { reader = bytes.NewReader(nil) } req := httptest.NewRequest(method, path, reader) if tok != "" { req.Header.Set("Authorization", "Bearer "+tok) } if body != nil { req.Header.Set("Content-Type", "application/json") } w := httptest.NewRecorder() e.router.ServeHTTP(w, req) return w } // jsonBody 解析响应体为 map。 func jsonBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any { t.Helper() var out map[string]any if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { t.Fatalf("unmarshal %q: %v", w.Body.String(), err) } return out } // jsonUnmarshal 解析响应体到任意目标(slice/struct)。 func jsonUnmarshal(w *httptest.ResponseRecorder, dst any) error { return json.Unmarshal(w.Body.Bytes(), dst) } // errCode 取 error.code 字段(错误响应契约的核心)。 func errCode(t *testing.T, w *httptest.ResponseRecorder) string { t.Helper() body := jsonBody(t, w) e, _ := body["error"].(map[string]any) if e == nil { t.Fatalf("no error object in %q", w.Body.String()) } return fmt.Sprint(e["code"]) } // seedLib 建一个 root 落在 BooksDir 内的库(fake 行 + 真实目录),返回 id 与 root。 func (e *testEnv) seedLib(t *testing.T, name string) (int64, string) { t.Helper() root := filepath.Join(e.booksDir, name) if err := os.MkdirAll(root, 0o755); err != nil { t.Fatal(err) } return e.libs.Seed(name, root), root } // httpOK 断言状态码,失败时带上响应体便于定位。 func httpOK(t *testing.T, w *httptest.ResponseRecorder, want int, what string) { t.Helper() if w.Code != want { t.Fatalf("%s: want %d got %d body=%q", what, want, w.Code, w.Body.String()) } }