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()) } }