- 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)
233 lines
7.3 KiB
Go
233 lines
7.3 KiB
Go
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())
|
|
}
|
|
}
|