- 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)
143 lines
4.0 KiB
Go
143 lines
4.0 KiB
Go
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())
|
|
}
|
|
}
|