test: portsfake + router contract test + handler unit tests (Task 27)
- internal/ports/portsfake: hand-written in-memory fakes for all 9 ports
(Users/Libraries/Books/Progress/Bookmarks/RateLimiter/Scanner/Media/Uploads);
error semantics mirror the real store exactly — pgx.ErrNoRows for misses,
store.ErrLastAdmin guard, and *pgconn.PgError{Code:23505} for unique
violations (ports.IsUniqueViolation only accepts the PgError shape, so the
fakes must produce it to exercise the 409 branch without touching prod code)
- Media/Uploads fakes are hook-programmable: one field per error branch, so a
test can force e.g. CompleteErr=ErrIncomplete without stubbing the rest
- Scanner fake is mutex-guarded + WaitForScan: handler fires ScanLibraryByID
in a goroutine, tests stay deterministic
- router_test.go: TestRouterContract pins all 27 routes — any route table
change now fails the test explicitly
- handler unit tests (~30 cases, no PG/Redis): users CRUD branches (self-delete
400, last-admin 400, dup 409, 204 ok), library reserved names (contract:
code=bad_request message=reserved_name, per original impl), upload sentinel
mapping (413/400/404 per branch)
- NewRouter takes pure port interfaces; main.go distributes *store.Store
across the 5 store ports at the composition root
Full gate green: gofmt, vet, go test -p 1 (real PG+Redis, 0 skip)
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user