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