diff --git a/backend/cmd/webui/api/router.go b/backend/cmd/webui/api/router.go index 0a195c4..52e1e5b 100644 --- a/backend/cmd/webui/api/router.go +++ b/backend/cmd/webui/api/router.go @@ -7,18 +7,16 @@ import ( "booklib/cmd/webui/handlers" "booklib/internal/config" - "booklib/internal/media" - "booklib/internal/redispkg" - "booklib/internal/scanner" - "booklib/internal/store" - "booklib/internal/upload" + "booklib/internal/ports" ) -// NewRouter 是组合根:接受具体实现,分发给 handlers.New 的小口径 port 字段。 -// *store.Store 满足 5 个 store 接口,*redispkg.R 满足 RateLimiter。 -func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner, med *media.M, up *upload.U) *gin.Engine { +// NewRouter 只依赖 port 接口:main.go 传具体实现(*store.Store 满足 5 个 store +// 接口,*redispkg.R 满足 RateLimiter),测试传 portsfake。 +func NewRouter(cfg *config.Config, users ports.UserStore, libs ports.LibraryStore, books ports.BookStore, + progress ports.ProgressStore, bookmarks ports.BookmarkStore, rl ports.RateLimiter, + sc ports.Scanner, med ports.Media, up ports.UploadSessions) *gin.Engine { gin.SetMode(gin.ReleaseMode) - h := handlers.New(cfg, st, st, st, st, st, rdb, sc, med, up) + h := handlers.New(cfg, users, libs, books, progress, bookmarks, rl, sc, med, up) r := gin.New() if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil { panic(e) @@ -31,17 +29,17 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner p := g.Group("", h.AuthMw()) p.GET("/auth/me", h.Me) - users := p.Group("/users", h.AdminOnly()) - users.GET("", h.ListUsers) - users.POST("", h.CreateUser) - users.DELETE("/:id", h.DeleteUser) + usersGrp := p.Group("/users", h.AdminOnly()) + usersGrp.GET("", h.ListUsers) + usersGrp.POST("", h.CreateUser) + usersGrp.DELETE("/:id", h.DeleteUser) - libs := p.Group("/libraries") - libs.GET("", h.ListLibraries) - libs.POST("", h.AdminOnly(), h.CreateLibrary) - libs.POST("/:id/scan", h.AdminOnly(), h.ScanLibrary) - libs.POST("/:id/upload", h.AdminOnly(), h.Upload) - libs.POST("/:id/upload/init", h.AdminOnly(), h.UploadInit) + libsGrp := p.Group("/libraries") + libsGrp.GET("", h.ListLibraries) + libsGrp.POST("", h.AdminOnly(), h.CreateLibrary) + libsGrp.POST("/:id/scan", h.AdminOnly(), h.ScanLibrary) + libsGrp.POST("/:id/upload", h.AdminOnly(), h.Upload) + libsGrp.POST("/:id/upload/init", h.AdminOnly(), h.UploadInit) uploads := p.Group("/uploads", h.AdminOnly()) uploads.GET("/:uid", h.UploadStatus) diff --git a/backend/cmd/webui/api/router_test.go b/backend/cmd/webui/api/router_test.go index f1e1037..2519cfc 100644 --- a/backend/cmd/webui/api/router_test.go +++ b/backend/cmd/webui/api/router_test.go @@ -7,7 +7,6 @@ import ( "time" "booklib/internal/config" - "booklib/internal/redispkg" ) func testCfg() *config.Config { @@ -16,7 +15,7 @@ func testCfg() *config.Config { } func TestHealthz(t *testing.T) { - r := NewRouter(testCfg(), nil, redispkg.New(""), nil, nil, nil) + r := NewRouter(testCfg(), nil, nil, nil, nil, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -24,3 +23,56 @@ func TestHealthz(t *testing.T) { t.Fatalf("healthz = %d, want 200", w.Code) } } + +// TestRouterContract 钉死全量路由表(Task 27):增删改任何路由都会使此测试失败, +// 必须显式更新——路由即对外契约。 +func TestRouterContract(t *testing.T) { + r := NewRouter(testCfg(), nil, nil, nil, nil, nil, nil, nil, nil, nil) + want := map[string]bool{ + "GET /api/healthz": true, + "POST /api/auth/login": true, + "GET /api/auth/me": true, + "GET /api/users": true, + "POST /api/users": true, + "DELETE /api/users/:id": true, + "GET /api/libraries": true, + "POST /api/libraries": true, + "POST /api/libraries/:id/scan": true, + "POST /api/libraries/:id/upload": true, + "POST /api/libraries/:id/upload/init": true, + "GET /api/uploads/:uid": true, + "POST /api/uploads/:uid/complete": true, + "PUT /api/uploads/:uid/parts/:index": true, + "GET /api/books": true, + "GET /api/books/:id": true, + "DELETE /api/books/:id": true, + "GET /api/books/:id/cover": true, + "GET /api/books/:id/file": true, + "GET /api/books/:id/pages": true, + "GET /api/books/:id/pages/:n": true, + "PUT /api/books/:id/progress": true, + "GET /api/progress": true, + "GET /api/books/:id/bookmarks": true, + "POST /api/books/:id/bookmarks": true, + "PATCH /api/bookmarks/:id": true, + "DELETE /api/bookmarks/:id": true, + } + got := map[string]bool{} + for _, rt := range r.Routes() { + key := rt.Method + " " + rt.Path + if got[key] { + t.Errorf("duplicate route %s", key) + } + got[key] = true + } + for k := range want { + if !got[k] { + t.Errorf("missing route %s", k) + } + } + for k := range got { + if !want[k] { + t.Errorf("unexpected route %s", k) + } + } +} diff --git a/backend/cmd/webui/handlers/auth_test.go b/backend/cmd/webui/handlers/auth_test.go index b4b768e..94222b5 100644 --- a/backend/cmd/webui/handlers/auth_test.go +++ b/backend/cmd/webui/handlers/auth_test.go @@ -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) diff --git a/backend/cmd/webui/handlers/libraries_unit_test.go b/backend/cmd/webui/handlers/libraries_unit_test.go new file mode 100644 index 0000000..9a9c029 --- /dev/null +++ b/backend/cmd/webui/handlers/libraries_unit_test.go @@ -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()) + } +} diff --git a/backend/cmd/webui/handlers/unit_harness_test.go b/backend/cmd/webui/handlers/unit_harness_test.go new file mode 100644 index 0000000..71db936 --- /dev/null +++ b/backend/cmd/webui/handlers/unit_harness_test.go @@ -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()) + } +} diff --git a/backend/cmd/webui/handlers/users_unit_test.go b/backend/cmd/webui/handlers/users_unit_test.go new file mode 100644 index 0000000..240dd8b --- /dev/null +++ b/backend/cmd/webui/handlers/users_unit_test.go @@ -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") +} diff --git a/backend/cmd/webui/main.go b/backend/cmd/webui/main.go index 1e960fd..e6a8dca 100644 --- a/backend/cmd/webui/main.go +++ b/backend/cmd/webui/main.go @@ -47,7 +47,8 @@ func main() { go sc.Run(ctx) serveErr := make(chan error, 1) - srv := &http.Server{Addr: cfg.Addr, Handler: api.NewRouter(cfg, st, rdb, sc, med, up), + // *store.Store 同时满足 5 个 store 接口;*redispkg.R 满足 RateLimiter。 + srv := &http.Server{Addr: cfg.Addr, Handler: api.NewRouter(cfg, st, st, st, st, st, rdb, sc, med, up), ReadHeaderTimeout: 10 * time.Second} go func() { log.Printf("listening on %s", cfg.Addr) diff --git a/backend/internal/ports/portsfake/services.go b/backend/internal/ports/portsfake/services.go new file mode 100644 index 0000000..8b01bab --- /dev/null +++ b/backend/internal/ports/portsfake/services.go @@ -0,0 +1,198 @@ +package portsfake + +import ( + "context" + "fmt" + "io" + "sync" + "time" + + "booklib/internal/ports" +) + +// ---------- RateLimiter ---------- + +// RateLimiter counts IncrWindow calls per key and returns a programmable +// window value. Default: always allow (return 1). +type RateLimiter struct { + mu sync.Mutex + calls map[string]int + Result int // value returned by IncrWindow; 0 means "call count" + Hook func(key string, n int) int // optional override +} + +func NewRateLimiter() *RateLimiter { return &RateLimiter{calls: map[string]int{}} } + +func (r *RateLimiter) IncrWindow(_ context.Context, key string, _ time.Duration) int { + r.mu.Lock() + r.calls[key]++ + n := r.calls[key] + r.mu.Unlock() + if r.Hook != nil { + return r.Hook(key, n) + } + if r.Result > 0 { + return r.Result + } + return n +} + +// Calls reports how many times IncrWindow was invoked for key. +func (r *RateLimiter) Calls(key string) int { + r.mu.Lock() + defer r.mu.Unlock() + return r.calls[key] +} + +// ---------- Scanner ---------- + +// Scanner records ScanLibraryByID calls. The handler invokes it in a goroutine, +// so reads go through the mutex; WaitForScan blocks until at least n calls +// landed (or the timeout expires) to keep tests deterministic. +type Scanner struct { + mu sync.Mutex + seen []int64 +} + +func NewScanner() *Scanner { return &Scanner{} } + +func (s *Scanner) ScanLibraryByID(_ context.Context, id int64) { + s.mu.Lock() + s.seen = append(s.seen, id) + s.mu.Unlock() +} + +// Seen returns the library ids scanned so far, in call order. +func (s *Scanner) Seen() []int64 { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]int64, len(s.seen)) + copy(out, s.seen) + return out +} + +// WaitForScan blocks until len(Seen()) >= n or timeout elapses; reports success. +func (s *Scanner) WaitForScan(n int, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if len(s.Seen()) >= n { + return true + } + time.Sleep(time.Millisecond) + } + return len(s.Seen()) >= n +} + +// ---------- Media ---------- + +// Media is a programmable stand-in for the media service. Defaults answer +// successfully with the configured fixture data; individual hooks let a test +// force one error branch without touching the others. +type Media struct { + Pages []string // PageIndex result + Chapters []ports.Chapter // ChaptersOf result + CoverErr error // EnsureCover result + IndexErr error // PageIndex result + PagePath string // EnsurePage result path (test writes the file first) + PageErr error // EnsurePage result + + EnsureCoverCalls int + EnsurePageCalls int +} + +func NewMedia() *Media { return &Media{} } + +func (m *Media) EnsureCover(_ context.Context, _ int64, _ string, _, _ int64, _, _ string) error { + m.EnsureCoverCalls++ + return m.CoverErr +} + +func (m *Media) EnsurePage(_ context.Context, _ int64, _, _ int64, _, _ string, _ int, _ []string) (string, error) { + m.EnsurePageCalls++ + if m.PageErr != nil { + return "", m.PageErr + } + return m.PagePath, nil +} + +func (m *Media) ChaptersOf(_ []string) []ports.Chapter { return m.Chapters } + +func (m *Media) PageIndex(_ context.Context, _ int64, _, _ int64, _, _ string) ([]string, error) { + if m.IndexErr != nil { + return nil, m.IndexErr + } + return m.Pages, nil +} + +func (m *Media) CacheBuster(size, modTS int64) string { return fmt.Sprintf("%d-%d", size, modTS) } + +// ---------- UploadSessions ---------- + +// Uploads is a programmable stand-in for the chunked-upload subsystem. +// Defaults simulate a happy session (uid "fakeuid…", LibraryID 1); +// hooks force error branches. +type Uploads struct { + UID string // returned by Init + LibID int64 // returned by LibraryID + Received []int64 // returned by Status + RelPath string // returned by Complete + InitErr error + LibErr error + StatusErr error + PutErr error + CompleteErr error + + PutCalls []int64 // part indices passed to PutPart + SweepCalls int + UniqueCalls int +} + +func NewUploads() *Uploads { + return &Uploads{UID: "0123456789abcdef0123456789abcdef", LibID: 1, RelPath: "book.cbz"} +} + +func (u *Uploads) Init(_ context.Context, _ int64, _ string, _, _ int64) (string, error) { + if u.InitErr != nil { + return "", u.InitErr + } + return u.UID, nil +} + +func (u *Uploads) LibraryID(_ context.Context, _ string) (int64, error) { + if u.LibErr != nil { + return 0, u.LibErr + } + return u.LibID, nil +} + +func (u *Uploads) Status(_ context.Context, _ string) ([]int64, error) { + if u.StatusErr != nil { + return nil, u.StatusErr + } + return u.Received, nil +} + +func (u *Uploads) PutPart(_ context.Context, _ string, index int64, body io.Reader, _ int64) error { + u.PutCalls = append(u.PutCalls, index) + if body != nil { // drain so callers using pipes don't block + io.Copy(io.Discard, body) + } + return u.PutErr +} + +func (u *Uploads) Complete(_ context.Context, _, _ string) (string, error) { + if u.CompleteErr != nil { + return "", u.CompleteErr + } + return u.RelPath, nil +} + +func (u *Uploads) Sweep(_ context.Context) error { + u.SweepCalls++ + return nil +} + +func (u *Uploads) UniquePath(root, name string) (string, error) { + u.UniqueCalls++ + return root + "/" + name, nil +} diff --git a/backend/internal/ports/portsfake/store.go b/backend/internal/ports/portsfake/store.go new file mode 100644 index 0000000..4c270e3 --- /dev/null +++ b/backend/internal/ports/portsfake/store.go @@ -0,0 +1,384 @@ +// Package portsfake provides hand-written, in-memory implementations of every +// interface in internal/ports. They let handler tests run without PG or Redis, +// while reproducing the real store's error semantics exactly (pgx.ErrNoRows for +// missing rows, store.ErrLastAdmin / store.ErrUniqueViolation for the guarded +// paths), so the branches under test behave as they do against the database. +// +// Fakes are safe for single-goroutine test use only; they are not locked. +package portsfake + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "booklib/internal/ports" + "booklib/internal/store" +) + +// uniqueViolation 复刻真 PG 的唯一约束冲突:store.IsUniqueViolation 只认 +// SQLSTATE 23505 的 *pgconn.PgError(生产链路里 pgx 原样透出),fake 必须同形。 +func uniqueViolation(constraint string) error { + return &pgconn.PgError{Code: "23505", ConstraintName: constraint} +} + +// compile-time proof that the fakes satisfy the consumer-side interfaces. +var ( + _ ports.UserStore = (*Users)(nil) + _ ports.LibraryStore = (*Libraries)(nil) + _ ports.BookStore = (*Books)(nil) + _ ports.ProgressStore = (*Progress)(nil) + _ ports.BookmarkStore = (*Bookmarks)(nil) + _ ports.RateLimiter = (*RateLimiter)(nil) + _ ports.Scanner = (*Scanner)(nil) + _ ports.Media = (*Media)(nil) + _ ports.UploadSessions = (*Uploads)(nil) +) + +// now is frozen per-fake-set so CreatedAt comparisons are stable within a test. +func now() time.Time { return time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC) } + +// ---------- UserStore ---------- + +type Users struct { + m map[int64]store.User + next int64 +} + +func NewUsers() *Users { return &Users{m: map[int64]store.User{}, next: 1} } + +// Seed adds a user and returns its id — test convenience, not part of the port. +func (u *Users) Seed(username, hash, role string) int64 { + id := u.next + u.next++ + u.m[id] = store.User{ID: id, Username: username, PasswordHash: hash, Role: role, CreatedAt: now()} + return id +} + +func (u *Users) CountUsers(_ context.Context) (int, error) { return len(u.m), nil } + +func (u *Users) CreateUser(_ context.Context, username, hash, role string) (int64, error) { + for _, v := range u.m { + if v.Username == username { + return 0, uniqueViolation("users_username_key") + } + } + return u.Seed(username, hash, role), nil +} + +func (u *Users) GetUserByName(_ context.Context, username string) (store.User, error) { + for _, v := range u.m { + if v.Username == username { + return v, nil + } + } + return store.User{}, pgx.ErrNoRows +} + +func (u *Users) GetUserByID(_ context.Context, id int64) (store.User, error) { + if v, ok := u.m[id]; ok { + return v, nil + } + return store.User{}, pgx.ErrNoRows +} + +func (u *Users) ListUsers(_ context.Context) ([]store.User, error) { + out := make([]store.User, 0, len(u.m)) + for _, v := range u.m { + out = append(out, v) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (u *Users) DeleteUser(_ context.Context, id int64) error { + v, ok := u.m[id] + if !ok { + return pgx.ErrNoRows + } + if v.Role == "admin" { + n := 0 + for _, x := range u.m { + if x.Role == "admin" { + n++ + } + } + if n <= 1 { + return store.ErrLastAdmin + } + } + delete(u.m, id) + return nil +} + +// ---------- LibraryStore ---------- + +type Libraries struct { + m map[int64]store.Library + next int64 +} + +func NewLibraries() *Libraries { return &Libraries{m: map[int64]store.Library{}, next: 1} } + +// Seed adds a library and returns its id — test convenience. +func (l *Libraries) Seed(name, root string) int64 { + id := l.next + l.next++ + l.m[id] = store.Library{ID: id, Name: name, RootPath: root, CreatedAt: now()} + return id +} + +func (l *Libraries) CreateLibrary(_ context.Context, name, root string) (int64, error) { + for _, v := range l.m { + if v.Name == name { + return 0, uniqueViolation("libraries_name_key") + } + } + return l.Seed(name, root), nil +} + +func (l *Libraries) ListLibraries(_ context.Context) ([]store.Library, error) { + out := make([]store.Library, 0, len(l.m)) + for _, v := range l.m { + out = append(out, v) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (l *Libraries) GetLibrary(_ context.Context, id int64) (store.Library, error) { + if v, ok := l.m[id]; ok { + return v, nil + } + return store.Library{}, pgx.ErrNoRows +} + +// ---------- BookStore ---------- + +type Books struct { + m map[int64]store.Book + next int64 +} + +func NewBooks() *Books { return &Books{m: map[int64]store.Book{}, next: 1} } + +// Seed adds a book and returns its id — test convenience. +func (b *Books) Seed(libID int64, path, title, format string, size, modTS int64, pageCount int) int64 { + id := b.next + b.next++ + b.m[id] = store.Book{ID: id, LibraryID: libID, Path: path, Title: title, Format: format, + FileSize: size, ModTS: modTS, PageCount: pageCount, State: "ok", AddedAt: now()} + return id +} + +func (b *Books) InsertBook(_ context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error) { + return b.Seed(libID, path, title, format, size, modTS, pageCount), nil +} + +func (b *Books) GetBook(_ context.Context, id int64) (store.Book, error) { + if v, ok := b.m[id]; ok { + return v, nil + } + return store.Book{}, pgx.ErrNoRows +} + +func (b *Books) ListBookMeta(_ context.Context, libID int64) (map[string]store.BookMeta, error) { + out := map[string]store.BookMeta{} + for _, v := range b.m { + if v.LibraryID == libID { + out[v.Path] = store.BookMeta{ID: v.ID, Size: v.FileSize, ModTS: v.ModTS, Format: v.Format} + } + } + return out, nil +} + +func (b *Books) UpdateBookFile(_ context.Context, id, size, modTS int64, pageCount int) error { + v, ok := b.m[id] + if !ok { + return pgx.ErrNoRows + } + v.FileSize, v.ModTS, v.PageCount = size, modTS, pageCount + b.m[id] = v + return nil +} + +func (b *Books) DeleteBookByPath(_ context.Context, libID int64, path string) error { + for id, v := range b.m { + if v.LibraryID == libID && v.Path == path { + delete(b.m, id) + return nil + } + } + return nil +} + +func (b *Books) DeleteBook(_ context.Context, id int64) error { + if _, ok := b.m[id]; !ok { + return pgx.ErrNoRows + } + delete(b.m, id) + return nil +} + +func (b *Books) SetBookState(_ context.Context, id int64, state, msg string) error { + v, ok := b.m[id] + if !ok { + return pgx.ErrNoRows + } + v.State, v.ErrMsg = state, msg + b.m[id] = v + return nil +} + +// ListBooks applies the same q/prefix filter as the SQL view (q matches title or +// path, prefix matches path prefix), then joins progress + library name. +func (b *Books) ListBooks(_ context.Context, libID int64, q, prefix string, userID int64) ([]store.BookView, error) { + out := []store.BookView{} + q = strings.ToLower(q) + for _, v := range b.m { + if libID != 0 && v.LibraryID != libID { + continue + } + if prefix != "" && !strings.HasPrefix(v.Path, prefix) { + continue + } + if q != "" && !strings.Contains(strings.ToLower(v.Title), q) && !strings.Contains(strings.ToLower(v.Path), q) { + continue + } + out = append(out, store.BookView{Book: v}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Book.ID < out[j].Book.ID }) + return out, nil +} + +func (b *Books) BookHashes(_ context.Context) (map[int64][2]int64, error) { + out := map[int64][2]int64{} + for _, v := range b.m { + out[v.ID] = [2]int64{v.FileSize, v.ModTS} + } + return out, nil +} + +// ---------- ProgressStore ---------- + +type progressKey struct { + userID, libID int64 + bookPath string +} + +type Progress struct { + m map[progressKey]store.Progress + libs *Libraries // for LibraryName join; may be nil + books *Books // for Title join; may be nil +} + +func NewProgress(libs *Libraries, books *Books) *Progress { + return &Progress{m: map[progressKey]store.Progress{}, libs: libs, books: books} +} + +func (p *Progress) UpsertProgress(_ context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error { + k := progressKey{userID, libID, bookPath} + old := p.m[k] + p.m[k] = store.Progress{LibraryID: libID, BookPath: bookPath, Locator: locator, + Percent: percent, UpdatedAt: now(), LibraryName: old.LibraryName, Title: old.Title} + return nil +} + +func (p *Progress) GetProgress(_ context.Context, userID, libID int64, bookPath string) (store.Progress, error) { + if v, ok := p.m[progressKey{userID, libID, bookPath}]; ok { + return v, nil + } + return store.Progress{}, pgx.ErrNoRows +} + +func (p *Progress) ListProgress(_ context.Context, userID int64) ([]store.Progress, error) { + out := []store.Progress{} + for k, v := range p.m { + if k.userID != userID { + continue + } + if p.libs != nil { + if l, e := p.libs.GetLibrary(context.Background(), v.LibraryID); e == nil { + v.LibraryName = l.Name + } + } + if p.books != nil { + for _, b := range p.books.m { + if b.LibraryID == v.LibraryID && b.Path == v.BookPath { + v.Title = b.Title + break + } + } + } + out = append(out, v) + } + sort.Slice(out, func(i, j int) bool { return out[i].BookPath < out[j].BookPath }) + return out, nil +} + +// ---------- BookmarkStore ---------- + +// bookmark pairs a row with its owner id. The real store carries the owner in a +// users-scoped join; the fake keeps it alongside so ListBookmarks/Patch/Delete +// can honour owner-scoped 404 semantics without a global index. +type bookmark struct { + row store.Bookmark + userID int64 +} + +type Bookmarks struct { + m map[int64]bookmark + next int64 +} + +func NewBookmarks() *Bookmarks { return &Bookmarks{m: map[int64]bookmark{}, next: 1} } + +func (bm *Bookmarks) InsertBookmark(_ context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error) { + id := bm.next + bm.next++ + bm.m[id] = bookmark{ + row: store.Bookmark{ID: id, LibraryID: libID, BookPath: bookPath, + Locator: locator, Percent: percent, Note: note, CreatedAt: now()}, + userID: userID, + } + return id, nil +} + +func (bm *Bookmarks) ListBookmarks(_ context.Context, userID, libID int64, bookPath string) ([]store.Bookmark, error) { + out := []store.Bookmark{} + for _, v := range bm.m { + if v.userID == userID && v.row.LibraryID == libID && v.row.BookPath == bookPath { + out = append(out, v.row) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Percent != out[j].Percent { + return out[i].Percent < out[j].Percent + } + return out[i].ID < out[j].ID + }) + return out, nil +} + +func (bm *Bookmarks) UpdateBookmarkNote(_ context.Context, userID, id int64, note string) (bool, error) { + v, ok := bm.m[id] + if !ok || v.userID != userID { // owner-scoped: foreign id is a 404, not a 403 + return false, nil + } + v.row.Note = note + bm.m[id] = v + return true, nil +} + +func (bm *Bookmarks) DeleteBookmark(_ context.Context, userID, id int64) (bool, error) { + v, ok := bm.m[id] + if !ok || v.userID != userID { + return false, nil + } + delete(bm.m, id) + return true, nil +}