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:
2026-09-14 23:23:10 +08:00
parent 7baadedeb4
commit cc1470f6e4
9 changed files with 1184 additions and 23 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, strin
med := media.New(cfg, rdb)
up := upload.New(cfg.BooksDir, cfg.UploadMaxMB)
sc := scanner.New(st, cfg, rdb, up)
r := api.NewRouter(cfg, st, rdb, sc, med, up)
r := api.NewRouter(cfg, st, st, st, st, st, rdb, sc, med, up)
if u := os.Getenv("REDIS_URL"); u != "" { // 测试卫生: 共享 redis 上重置登录限流桶, 防跨测试累计 429
if opt, e := redis.ParseURL(u); e == nil {
rc := redis.NewClient(opt)
@@ -0,0 +1,232 @@
package handlers_test
import (
"net/http"
"strconv"
"strings"
"testing"
"time"
"booklib/internal/ports"
"booklib/internal/upload"
)
// ---------- libraries (Task 27, portsfake) ----------
func TestUnit_ListLibraries(t *testing.T) {
e := newTestEnv(t)
e.seedLib(t, "comics")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodGet, "/api/libraries", atok, nil)
httpOK(t, w, 200, "list libraries")
var libs []map[string]any
if err := jsonUnmarshal(w, &libs); err != nil {
t.Fatal(err)
}
if len(libs) != 1 || libs[0]["name"] != "comics" {
t.Fatalf("unexpected libs %v", libs)
}
}
func TestUnit_CreateLibrary_ReservedName(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
// 保留名清单以 media/reserved.go 为准(B8);大小写不敏感。
// 契约(与重构前一致): code="bad_request", message="reserved_name"
for _, name := range []string{"cache", ".uploads", ".trash", "CACHE", "Cache"} {
w := e.do(t, http.MethodPost, "/api/libraries", atok, map[string]string{"Name": name})
if w.Code != 400 || errCode(t, w) != "bad_request" || !strings.Contains(w.Body.String(), "reserved_name") {
t.Fatalf("name=%q want 400/bad_request/reserved_name got %d %s", name, w.Code, w.Body.String())
}
}
}
func TestUnit_CreateLibrary_BadName(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
for _, name := range []string{"", "..", "///"} {
w := e.do(t, http.MethodPost, "/api/libraries", atok, map[string]string{"Name": name})
httpOK(t, w, 400, "bad name "+name)
}
}
func TestUnit_CreateLibrary_Duplicate(t *testing.T) {
e := newTestEnv(t)
e.seedLib(t, "dup")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/libraries", atok, map[string]string{"Name": "dup"})
httpOK(t, w, 409, "duplicate library")
if code := errCode(t, w); code != "exists" {
t.Fatalf("error code want exists got %q", code)
}
}
func TestUnit_CreateLibrary_OK(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/libraries", atok, map[string]string{"Name": "new lib"})
httpOK(t, w, 201, "create library")
body := jsonBody(t, w)
if body["name"] != "new lib" {
t.Fatalf("unexpected body %v", body)
}
}
func TestUnit_ScanLibrary_NotFound(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/libraries/999/scan", atok, nil)
httpOK(t, w, 404, "scan missing library")
}
func TestUnit_ScanLibrary_Accepted(t *testing.T) {
e := newTestEnv(t)
libID, _ := e.seedLib(t, "scannable")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/libraries/"+strconv.FormatInt(libID, 10)+"/scan", atok, nil)
httpOK(t, w, 202, "scan accepted")
if !e.sc.WaitForScan(1, time.Second) {
t.Fatalf("ScanLibraryByID never called; seen=%v", e.sc.Seen())
}
if seen := e.sc.Seen(); seen[0] != libID {
t.Fatalf("scanned lib %d want %d", seen[0], libID)
}
}
func TestUnit_ScanLibrary_MemberForbidden(t *testing.T) {
e := newTestEnv(t)
libID, _ := e.seedLib(t, "nope")
mtok := e.token(t, "member", 2)
w := e.do(t, http.MethodPost, "/api/libraries/"+strconv.FormatInt(libID, 10)+"/scan", mtok, nil)
httpOK(t, w, 403, "member scan")
}
// ---------- uploads: sentinel → status mapping ----------
func TestUnit_UploadInit_MapsSentinels(t *testing.T) {
libIDPath := func(e *testEnv) string {
id, _ := e.seedLib(t, "up")
return "/api/libraries/" + strconv.FormatInt(id, 10) + "/upload/init"
}
cases := []struct {
name string
initErr error
wantCode int
wantErr string
}{
{"too large", ports.ErrTooLarge, 413, "too_large"},
{"bad name", upload.ErrBadName, 400, "bad_request"},
{"bad format", upload.ErrBadFormat, 400, "bad_format"},
{"bad size", upload.ErrBadSize, 400, "bad_request"},
{"bad chunk", upload.ErrBadChunk, 400, "bad_request"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
e := newTestEnv(t)
e.up.InitErr = tc.initErr
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, libIDPath(e), atok,
map[string]any{"name": "b.cbz", "size": 10, "chunkSize": 4})
httpOK(t, w, tc.wantCode, "init "+tc.name)
if code := errCode(t, w); code != tc.wantErr {
t.Fatalf("error code want %q got %q", tc.wantErr, code)
}
})
}
}
func TestUnit_UploadInit_OK(t *testing.T) {
e := newTestEnv(t)
id, _ := e.seedLib(t, "up")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/libraries/"+strconv.FormatInt(id, 10)+"/upload/init", atok,
map[string]any{"name": "b.cbz", "size": 10, "chunkSize": 4})
httpOK(t, w, 200, "init ok")
body := jsonBody(t, w)
if body["uploadId"] != e.up.UID {
t.Fatalf("uploadId want %q got %v", e.up.UID, body["uploadId"])
}
}
func TestUnit_UploadInit_BadBody(t *testing.T) {
e := newTestEnv(t)
id, _ := e.seedLib(t, "up")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/libraries/"+strconv.FormatInt(id, 10)+"/upload/init", atok, nil)
httpOK(t, w, 400, "init without json body")
}
func TestUnit_UploadStatus_NotFound(t *testing.T) {
e := newTestEnv(t)
e.up.StatusErr = ports.ErrNotFound
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodGet, "/api/uploads/"+e.up.UID, atok, nil)
httpOK(t, w, 404, "status missing upload")
if code := errCode(t, w); code != "not_found" {
t.Fatalf("error code want not_found got %q", code)
}
}
func TestUnit_UploadStatus_OK(t *testing.T) {
e := newTestEnv(t)
e.up.Received = []int64{0, 2}
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodGet, "/api/uploads/"+e.up.UID, atok, nil)
httpOK(t, w, 200, "status ok")
var body struct {
Received []int64 `json:"received"`
}
if err := jsonUnmarshal(w, &body); err != nil {
t.Fatal(err)
}
if len(body.Received) != 2 || body.Received[1] != 2 {
t.Fatalf("received want [0 2] got %v", body.Received)
}
}
func TestUnit_UploadPart_BadIndex(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPut, "/api/uploads/"+e.up.UID+"/parts/abc", atok, nil)
httpOK(t, w, 400, "part bad index")
}
func TestUnit_UploadPart_TooBig(t *testing.T) {
e := newTestEnv(t)
e.up.PutErr = upload.ErrPartTooBig
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPut, "/api/uploads/"+e.up.UID+"/parts/0", atok, nil)
httpOK(t, w, 413, "part too big")
}
func TestUnit_UploadComplete_Incomplete(t *testing.T) {
e := newTestEnv(t)
e.seedLib(t, "up") // fake LibraryID=1 与 seed 的第一条对齐
e.up.CompleteErr = ports.ErrIncomplete
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/uploads/"+e.up.UID+"/complete", atok, nil)
httpOK(t, w, 400, "complete incomplete")
}
func TestUnit_UploadComplete_OK(t *testing.T) {
e := newTestEnv(t)
e.seedLib(t, "up")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/uploads/"+e.up.UID+"/complete", atok, nil)
httpOK(t, w, 202, "complete ok")
body := jsonBody(t, w)
if body["path"] != e.up.RelPath {
t.Fatalf("path want %q got %v", e.up.RelPath, body["path"])
}
}
func TestUnit_UploadComplete_LibGone(t *testing.T) {
e := newTestEnv(t) // LibraryID=1 但库里没有 id=1 → GetLibrary ErrNoRows → 404? dbErr → 500
e.up.LibID = 42
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/uploads/"+e.up.UID+"/complete", atok, nil)
// dbErr 把 pgx.ErrNoRows 归为 500(非连接类),契约与真库一致
if w.Code != 500 {
t.Fatalf("lib gone want 500 got %d body=%s", w.Code, w.Body.String())
}
}
@@ -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())
}
}
@@ -0,0 +1,154 @@
package handlers_test
import (
"context"
"errors"
"net/http"
"strconv"
"testing"
"github.com/jackc/pgx/v5"
"booklib/internal/auth"
)
// ---------- users: admin-only CRUD branches (Task 27, portsfake) ----------
func TestUnit_ListUsers(t *testing.T) {
e := newTestEnv(t)
hash, _ := auth.HashPassword("password1234")
e.users.Seed("admin", hash, "admin")
e.users.Seed("member1", hash, "member")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodGet, "/api/users", atok, nil)
httpOK(t, w, 200, "list users")
var users []map[string]any
if err := jsonUnmarshal(w, &users); err != nil {
t.Fatal(err)
}
if len(users) != 2 {
t.Fatalf("want 2 users got %d", len(users))
}
}
func TestUnit_ListUsers_MemberForbidden(t *testing.T) {
e := newTestEnv(t)
mtok := e.token(t, "member", 2)
w := e.do(t, http.MethodGet, "/api/users", mtok, nil)
httpOK(t, w, 403, "member list users")
}
func TestUnit_CreateUser_RoleValidation(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
for _, role := range []string{"superadmin", "", "Member"} {
w := e.do(t, http.MethodPost, "/api/users", atok,
map[string]string{"Username": "u", "Password": "password1234", "Role": role})
httpOK(t, w, 400, "create user role="+role)
}
}
func TestUnit_CreateUser_ShortPassword(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/users", atok,
map[string]string{"Username": "u", "Password": "short", "Role": "member"})
httpOK(t, w, 400, "short password")
}
func TestUnit_CreateUser_DuplicateName(t *testing.T) {
e := newTestEnv(t)
hash, _ := auth.HashPassword("password1234")
e.users.Seed("existing", hash, "member")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/users", atok,
map[string]string{"Username": "existing", "Password": "password1234", "Role": "member"})
httpOK(t, w, 409, "duplicate name")
if code := errCode(t, w); code != "exists" {
t.Fatalf("error code want 'exists' got %q", code)
}
}
func TestUnit_CreateUser_OK(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/users", atok,
map[string]string{"Username": "newbie", "Password": "password1234", "Role": "member"})
httpOK(t, w, 201, "create user")
body := jsonBody(t, w)
if body["username"] != "newbie" || body["role"] != "member" {
t.Fatalf("unexpected body %v", body)
}
}
func TestUnit_DeleteUser_BadID(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodDelete, "/api/users/notanum", atok, nil)
httpOK(t, w, 400, "bad id")
}
func TestUnit_DeleteUser_Self(t *testing.T) {
e := newTestEnv(t)
uid := e.users.Seed("self", "hash", "admin")
atok := e.token(t, "admin", uid) // 自己删自己
w := e.do(t, http.MethodDelete, "/api/users/"+strconv.FormatInt(uid, 10), atok, nil)
httpOK(t, w, 400, "delete self")
}
func TestUnit_DeleteUser_LastAdmin(t *testing.T) {
e := newTestEnv(t)
uid := e.users.Seed("lastadmin", "hash", "admin")
atok := e.token(t, "admin", 999) // 另一个(不存在的)操作者
w := e.do(t, http.MethodDelete, "/api/users/"+strconv.FormatInt(uid, 10), atok, nil)
httpOK(t, w, 400, "last admin")
}
func TestUnit_DeleteUser_NotFound(t *testing.T) {
e := newTestEnv(t)
e.users.Seed("other", "hash", "admin")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodDelete, "/api/users/99999", atok, nil)
httpOK(t, w, 404, "missing user")
}
func TestUnit_DeleteUser_OK(t *testing.T) {
e := newTestEnv(t)
e.users.Seed("admin", "hash", "admin") // 保住 last-admin 保护不触发
target := e.users.Seed("todelete", "hash", "member")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodDelete, "/api/users/"+strconv.FormatInt(target, 10), atok, nil)
httpOK(t, w, 204, "delete user")
if _, err := e.users.GetUserByID(context.Background(), target); !errors.Is(err, pgx.ErrNoRows) {
t.Fatal("user should be gone")
}
}
func TestUnit_Me_OK(t *testing.T) {
e := newTestEnv(t)
uid := e.users.Seed("me", "hash", "member")
mtok := e.token(t, "member", uid)
w := e.do(t, http.MethodGet, "/api/auth/me", mtok, nil)
httpOK(t, w, 200, "me")
body := jsonBody(t, w)
if body["username"] != "me" {
t.Fatalf("username want 'me' got %v", body["username"])
}
}
func TestUnit_Me_UserGone(t *testing.T) {
e := newTestEnv(t)
mtok := e.token(t, "member", 99999)
w := e.do(t, http.MethodGet, "/api/auth/me", mtok, nil)
httpOK(t, w, 401, "me after user gone")
}
func TestUnit_Me_NoToken(t *testing.T) {
e := newTestEnv(t)
w := e.do(t, http.MethodGet, "/api/auth/me", "", nil)
httpOK(t, w, 401, "me without token")
}