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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user