Assignment 4: tests, interface refactor, e2e suite
This commit is contained in:
@@ -2,22 +2,28 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"5803025048/W04/internal/auth"
|
||||
"5803025048/W04/internal/middleware"
|
||||
"5803025048/W04/internal/models"
|
||||
"5803025048/W04/internal/store"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// bcrypt menolak password lebih dari 72 byte.
|
||||
const maxPasswordBytes = 72
|
||||
|
||||
type AuthHandler struct {
|
||||
userStore *store.UserStore
|
||||
sessionStore *store.SessionStore
|
||||
userStore UserStore
|
||||
sessionStore SessionStore
|
||||
}
|
||||
|
||||
func NewAuthHandler(userStore *store.UserStore, sessionStore *store.SessionStore) *AuthHandler {
|
||||
func NewAuthHandler(userStore UserStore, sessionStore SessionStore) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
userStore: userStore,
|
||||
sessionStore: sessionStore,
|
||||
@@ -30,26 +36,22 @@ type authRequest struct {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req authRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if len(req.Username) < 3 || len(req.Username) > 32 || req.Password == "" {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
n := utf8.RuneCountInString(req.Username)
|
||||
if n < 3 || n > 32 || req.Password == "" || len(req.Password) > maxPasswordBytes {
|
||||
writeError(w, http.StatusBadRequest, "Invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,36 +62,41 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.userStore.Create(r.Context(), user); err != nil {
|
||||
http.Error(w, "Username already exists or create failed", http.StatusBadRequest)
|
||||
if errors.Is(err, store.ErrUsernameTaken) {
|
||||
writeError(w, http.StatusConflict, "Username already exists")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(user)
|
||||
writeJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req authRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userStore.GetByUsername(r.Context(), req.Username)
|
||||
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusUnauthorized, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||
writeError(w, http.StatusUnauthorized, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -100,43 +107,33 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.sessionStore.Create(r.Context(), session); err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(session)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"session_token": session.Token})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
token, ok := middleware.BearerToken(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := authHeader
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
token = authHeader[7:]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
if _, err := h.sessionStore.Get(r.Context(), token); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sessionStore.Delete(r.Context(), token); err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"message": "logged out"})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"5803025048/W04/internal/auth"
|
||||
"5803025048/W04/internal/models"
|
||||
)
|
||||
|
||||
func credsBody(username, password string) string {
|
||||
b, _ := json.Marshal(map[string]string{"username": username, "password": password})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
const password = "s3cret-pass-xyz"
|
||||
|
||||
newHandler := func() (*AuthHandler, *fakeUserStore) {
|
||||
us := newFakeUserStore()
|
||||
return NewAuthHandler(us, newFakeSessionStore()), us
|
||||
}
|
||||
register := func(h *AuthHandler, body string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h.Register(rec, newJSONRequest(http.MethodPost, "/register", body))
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("success returns 201 with id and username", func(t *testing.T) {
|
||||
h, _ := newHandler()
|
||||
rec := register(h, credsBody("alice", password))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
if id, ok := m["id"].(string); !ok || id == "" {
|
||||
t.Fatalf("id must be a non-empty string, got %v", m["id"])
|
||||
}
|
||||
if m["username"] != "alice" {
|
||||
t.Fatalf("username = %v, want alice", m["username"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate username returns 409", func(t *testing.T) {
|
||||
h, _ := newHandler()
|
||||
if rec := register(h, credsBody("alice", password)); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("first register status = %d, want 201", rec.Code)
|
||||
}
|
||||
rec := register(h, credsBody("alice", password))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
badCases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"empty username returns 400", credsBody("", password)},
|
||||
{"empty password returns 400", credsBody("alice", "")},
|
||||
{"username of 2 characters returns 400", credsBody("ab", password)},
|
||||
{"username of 33 characters returns 400", credsBody(strings.Repeat("a", 33), password)},
|
||||
{"malformed JSON returns 400", `{"username": "alice", "password":`},
|
||||
{"empty body returns 400", ``},
|
||||
{"JSON array body returns 400", `["alice","pw"]`},
|
||||
{"password longer than bcrypt limit returns 400", credsBody("alice", strings.Repeat("p", 73))},
|
||||
}
|
||||
for _, tc := range badCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, us := newHandler()
|
||||
rec := register(h, tc.body)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if len(us.users) != 0 {
|
||||
t.Fatal("no user must be stored for an invalid request")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("usernames of exactly 3 and 32 characters are accepted", func(t *testing.T) {
|
||||
h, _ := newHandler()
|
||||
for _, name := range []string{"abc", strings.Repeat("b", 32)} {
|
||||
if rec := register(h, credsBody(name, password)); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("username %q: status = %d, want 201", name, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success body contains no password material", func(t *testing.T) {
|
||||
h, _ := newHandler()
|
||||
rec := register(h, credsBody("alice", password))
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
for k := range m {
|
||||
if strings.Contains(strings.ToLower(k), "password") {
|
||||
t.Fatalf("response must not contain a password-like key, found %q", k)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), password) {
|
||||
t.Fatal("response must not contain the plaintext password")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store failure returns 500", func(t *testing.T) {
|
||||
h, us := newHandler()
|
||||
us.createErr = errors.New("db down")
|
||||
rec := register(h, credsBody("alice", password))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
const username = "alice"
|
||||
const password = "correct-horse"
|
||||
|
||||
setup := func(t *testing.T) (*AuthHandler, *fakeUserStore, *fakeSessionStore) {
|
||||
t.Helper()
|
||||
us, ss := newFakeUserStore(), newFakeSessionStore()
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if err := us.Create(context.Background(), &models.User{ID: "u1", Username: username, PasswordHash: hash}); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
return NewAuthHandler(us, ss), us, ss
|
||||
}
|
||||
login := func(h *AuthHandler, body string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h.Login(rec, newJSONRequest(http.MethodPost, "/login", body))
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("success returns 200 with session_token", func(t *testing.T) {
|
||||
h, _, ss := setup(t)
|
||||
rec := login(h, credsBody(username, password))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
tok, ok := m["session_token"].(string)
|
||||
if !ok || tok == "" {
|
||||
t.Fatalf("session_token must be a non-empty string, got %v", m["session_token"])
|
||||
}
|
||||
if !ss.has(tok) {
|
||||
t.Fatal("the returned token must be stored in the session store")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong password returns 401", func(t *testing.T) {
|
||||
h, _, _ := setup(t)
|
||||
rec := login(h, credsBody(username, "wrong-password"))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("unknown username returns 401", func(t *testing.T) {
|
||||
h, _, _ := setup(t)
|
||||
rec := login(h, credsBody("nobody", password))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("401 bodies for wrong password and unknown user are identical", func(t *testing.T) {
|
||||
h, _, _ := setup(t)
|
||||
wrong := login(h, credsBody(username, "wrong-password"))
|
||||
unknown := login(h, credsBody("nobody", password))
|
||||
if wrong.Code != http.StatusUnauthorized || unknown.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("statuses = %d and %d, want 401 and 401", wrong.Code, unknown.Code)
|
||||
}
|
||||
if wrong.Body.String() != unknown.Body.String() {
|
||||
t.Fatalf("bodies differ:\n wrong password: %q\n unknown user: %q", wrong.Body.String(), unknown.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON returns 400", func(t *testing.T) {
|
||||
h, _, _ := setup(t)
|
||||
rec := login(h, `{"username":`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty body and JSON array return 400", func(t *testing.T) {
|
||||
h, _, _ := setup(t)
|
||||
for _, body := range []string{``, `[1,2,3]`} {
|
||||
if rec := login(h, body); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("body %q: status = %d, want 400", body, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user store failure returns 500", func(t *testing.T) {
|
||||
h, us, _ := setup(t)
|
||||
us.getErr = errors.New("db down")
|
||||
rec := login(h, credsBody(username, password))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("session store failure returns 500", func(t *testing.T) {
|
||||
h, _, ss := setup(t)
|
||||
ss.createErr = errors.New("db down")
|
||||
rec := login(h, credsBody(username, password))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
setup := func() (*AuthHandler, *fakeSessionStore) {
|
||||
ss := newFakeSessionStore()
|
||||
ss.sessions["tok-valid"] = &models.Session{Token: "tok-valid", UserID: "u1"}
|
||||
ss.sessions["tok-other"] = &models.Session{Token: "tok-other", UserID: "u2"}
|
||||
return NewAuthHandler(newFakeUserStore(), ss), ss
|
||||
}
|
||||
logout := func(h *AuthHandler, authz string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||
if authz != "" {
|
||||
req.Header.Set("Authorization", authz)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.Logout(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("valid token returns 204 and removes the session", func(t *testing.T) {
|
||||
h, ss := setup()
|
||||
rec := logout(h, "Bearer tok-valid")
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Fatalf("body must be empty, got %q", rec.Body.String())
|
||||
}
|
||||
if ss.has("tok-valid") {
|
||||
t.Fatal("session store must no longer contain the token")
|
||||
}
|
||||
if !ss.has("tok-other") {
|
||||
t.Fatal("other sessions must not be deleted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing token returns 401", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := logout(h, "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("unknown token returns 401", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := logout(h, "Bearer not-a-real-token")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("second logout with the same token returns 401", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
if rec := logout(h, "Bearer tok-valid"); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("first logout status = %d, want 204", rec.Code)
|
||||
}
|
||||
rec := logout(h, "Bearer tok-valid")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("second logout status = %d, want 401", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Basic scheme returns 401 and keeps the session", func(t *testing.T) {
|
||||
h, ss := setup()
|
||||
rec := logout(h, "Basic tok-valid")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if !ss.has("tok-valid") {
|
||||
t.Fatal("session must not be deleted by a non-Bearer header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("session lookup failure returns 500", func(t *testing.T) {
|
||||
h, ss := setup()
|
||||
ss.getErr = errors.New("db down")
|
||||
rec := logout(h, "Bearer tok-valid")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("session delete failure returns 500", func(t *testing.T) {
|
||||
h, ss := setup()
|
||||
ss.deleteErr = errors.New("db down")
|
||||
rec := logout(h, "Bearer tok-valid")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"5803025048/W04/internal/middleware"
|
||||
"5803025048/W04/internal/models"
|
||||
"5803025048/W04/internal/store"
|
||||
)
|
||||
|
||||
// ---------- fake stores (in-memory, buatan tangan) ----------
|
||||
|
||||
type fakeUserStore struct {
|
||||
mu sync.Mutex
|
||||
users map[string]*models.User // key: username
|
||||
createErr error
|
||||
getErr error
|
||||
}
|
||||
|
||||
func newFakeUserStore() *fakeUserStore {
|
||||
return &fakeUserStore{users: map[string]*models.User{}}
|
||||
}
|
||||
|
||||
func (f *fakeUserStore) Create(_ context.Context, user *models.User) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
if _, exists := f.users[user.Username]; exists {
|
||||
return store.ErrUsernameTaken
|
||||
}
|
||||
cp := *user
|
||||
f.users[user.Username] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserStore) GetByUsername(_ context.Context, username string) (*models.User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
u, ok := f.users[username]
|
||||
if !ok {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
type fakeSessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*models.Session // key: token
|
||||
createErr error
|
||||
getErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func newFakeSessionStore() *fakeSessionStore {
|
||||
return &fakeSessionStore{sessions: map[string]*models.Session{}}
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) Create(_ context.Context, session *models.Session) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
cp := *session
|
||||
f.sessions[session.Token] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) Get(_ context.Context, token string) (*models.Session, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
s, ok := f.sessions[token]
|
||||
if !ok {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
cp := *s
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) Delete(_ context.Context, token string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.deleteErr != nil {
|
||||
return f.deleteErr
|
||||
}
|
||||
delete(f.sessions, token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) has(token string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, ok := f.sessions[token]
|
||||
return ok
|
||||
}
|
||||
|
||||
type fakeSnippetStore struct {
|
||||
mu sync.Mutex
|
||||
snippets map[string]*models.Snippet // key: id
|
||||
createErr error
|
||||
getErr error
|
||||
listErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func newFakeSnippetStore() *fakeSnippetStore {
|
||||
return &fakeSnippetStore{snippets: map[string]*models.Snippet{}}
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) seed(id, title, content, userID string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.snippets[id] = &models.Snippet{ID: id, Title: title, Content: content, UserID: userID}
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) Create(_ context.Context, snippet *models.Snippet) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
cp := *snippet
|
||||
f.snippets[snippet.ID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) GetByID(_ context.Context, id string) (*models.Snippet, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
s, ok := f.snippets[id]
|
||||
if !ok {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
cp := *s
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// GetByUserID sengaja mengembalikan nil (bukan slice kosong) jika tidak ada hasil,
|
||||
// supaya handler terbukti menghasilkan "[]" dan bukan "null".
|
||||
func (f *fakeSnippetStore) GetByUserID(_ context.Context, userID string) ([]*models.Snippet, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
var out []*models.Snippet
|
||||
for _, s := range f.snippets {
|
||||
if s.UserID == userID {
|
||||
cp := *s
|
||||
out = append(out, &cp)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) Delete(_ context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.deleteErr != nil {
|
||||
return f.deleteErr
|
||||
}
|
||||
if _, ok := f.snippets[id]; !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
delete(f.snippets, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) has(id string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, ok := f.snippets[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ---------- helper tes ----------
|
||||
|
||||
func newJSONRequest(method, target, body string) *http.Request {
|
||||
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
func withUser(req *http.Request, userID string) *http.Request {
|
||||
return req.WithContext(context.WithValue(req.Context(), middleware.UserIDKey, userID))
|
||||
}
|
||||
|
||||
func assertJSONContentType(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Fatalf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeMap(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("body is not a JSON object: %v (body=%q)", err, string(raw))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func assertKeys(t *testing.T, m map[string]any, want ...string) {
|
||||
t.Helper()
|
||||
got := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
got = append(got, k)
|
||||
}
|
||||
sort.Strings(got)
|
||||
sort.Strings(want)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("keys = %v, want exactly %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func errNotFoundForTest() error { return store.ErrNotFound }
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"5803025048/W04/internal/models"
|
||||
)
|
||||
|
||||
// Interface didefinisikan di package consumer (handler), bukan di store.
|
||||
// Tipe konkret di package store memenuhi interface ini tanpa meng-import handler.
|
||||
|
||||
type UserStore interface {
|
||||
Create(ctx context.Context, user *models.User) error
|
||||
GetByUsername(ctx context.Context, username string) (*models.User, error)
|
||||
}
|
||||
|
||||
type SessionStore interface {
|
||||
Create(ctx context.Context, session *models.Session) error
|
||||
Get(ctx context.Context, token string) (*models.Session, error)
|
||||
Delete(ctx context.Context, token string) error
|
||||
}
|
||||
|
||||
type SnippetStore interface {
|
||||
Create(ctx context.Context, snippet *models.Snippet) error
|
||||
GetByID(ctx context.Context, id string) (*models.Snippet, error)
|
||||
GetByUserID(ctx context.Context, userID string) ([]*models.Snippet, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// writeError selalu mengirim error sebagai JSON (Content-Type: application/json).
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
@@ -10,14 +10,13 @@ import (
|
||||
"5803025048/W04/internal/models"
|
||||
"5803025048/W04/internal/store"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type SnippetHandler struct {
|
||||
snippetStore *store.SnippetStore
|
||||
snippetStore SnippetStore
|
||||
}
|
||||
|
||||
func NewSnippetHandler(snippetStore *store.SnippetStore) *SnippetHandler {
|
||||
func NewSnippetHandler(snippetStore SnippetStore) *SnippetHandler {
|
||||
return &SnippetHandler{snippetStore: snippetStore}
|
||||
}
|
||||
|
||||
@@ -26,50 +25,32 @@ type snippetRequest struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (h *SnippetHandler) Snippets(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.List(w, r)
|
||||
case http.MethodPost:
|
||||
h.Create(w, r)
|
||||
default:
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SnippetHandler) SnippetByID(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.Get(w, r)
|
||||
case http.MethodDelete:
|
||||
h.Delete(w, r)
|
||||
default:
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
func snippetID(r *http.Request) string {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
|
||||
id = strings.TrimSuffix(path, "/")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (h *SnippetHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req snippetRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
req.Title = strings.TrimSpace(req.Title)
|
||||
req.Content = strings.TrimSpace(req.Content)
|
||||
if req.Title == "" || req.Content == "" {
|
||||
http.Error(w, "Title and content are required", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Title and content are required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,83 +62,87 @@ func (h *SnippetHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.snippetStore.Create(r.Context(), snippet); err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(snippet)
|
||||
writeJSON(w, http.StatusCreated, snippet)
|
||||
}
|
||||
|
||||
func (h *SnippetHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
snippets, err := h.snippetStore.GetAll(r.Context())
|
||||
snippets, err := h.snippetStore.GetByUserID(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
if snippets == nil {
|
||||
// Pastikan selalu "[]", bukan "null".
|
||||
snippets = []*models.Snippet{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(snippets)
|
||||
writeJSON(w, http.StatusOK, snippets)
|
||||
}
|
||||
|
||||
func (h *SnippetHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
id := snippetID(r)
|
||||
if id == "" {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
|
||||
id = strings.TrimSuffix(path, "/")
|
||||
}
|
||||
|
||||
if id == "" {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
snippet, err := h.snippetStore.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Snippet not found", http.StatusNotFound)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "Snippet not found")
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(snippet)
|
||||
writeJSON(w, http.StatusOK, snippet)
|
||||
}
|
||||
|
||||
func (h *SnippetHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
id := snippetID(r)
|
||||
if id == "" {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
|
||||
id = strings.TrimSuffix(path, "/")
|
||||
writeError(w, http.StatusBadRequest, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
if id == "" {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
snippet, err := h.snippetStore.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "Snippet not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
if snippet.UserID != userID {
|
||||
writeError(w, http.StatusForbidden, "Forbidden")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.snippetStore.Delete(r.Context(), id); err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "Snippet not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func snippetBody(title, content string) string {
|
||||
b, _ := json.Marshal(map[string]string{"title": title, "content": content})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestCreateSnippet(t *testing.T) {
|
||||
create := func(h *SnippetHandler, userID, body string) *httptest.ResponseRecorder {
|
||||
req := newJSONRequest(http.MethodPost, "/snippets", body)
|
||||
if userID != "" {
|
||||
req = withUser(req, userID)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.Create(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("success returns 201 with exactly id, title, content", func(t *testing.T) {
|
||||
fs := newFakeSnippetStore()
|
||||
h := NewSnippetHandler(fs)
|
||||
rec := create(h, "u1", snippetBody("My title", "console.log('hi')"))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
assertKeys(t, m, "id", "title", "content")
|
||||
id, _ := m["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("id must be a non-empty string")
|
||||
}
|
||||
if m["title"] != "My title" || m["content"] != "console.log('hi')" {
|
||||
t.Fatalf("title/content not echoed correctly: %v", m)
|
||||
}
|
||||
if !fs.has(id) {
|
||||
t.Fatal("snippet must be stored")
|
||||
}
|
||||
if fs.snippets[id].UserID != "u1" {
|
||||
t.Fatalf("stored owner = %q, want u1", fs.snippets[id].UserID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty title returns 400", func(t *testing.T) {
|
||||
h := NewSnippetHandler(newFakeSnippetStore())
|
||||
rec := create(h, "u1", snippetBody("", "content"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty content returns 400", func(t *testing.T) {
|
||||
h := NewSnippetHandler(newFakeSnippetStore())
|
||||
rec := create(h, "u1", snippetBody("title", ""))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("malformed JSON returns 400", func(t *testing.T) {
|
||||
h := NewSnippetHandler(newFakeSnippetStore())
|
||||
rec := create(h, "u1", `{"title": "x", "content":`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty body and JSON array return 400", func(t *testing.T) {
|
||||
h := NewSnippetHandler(newFakeSnippetStore())
|
||||
for _, body := range []string{``, `["a","b"]`} {
|
||||
if rec := create(h, "u1", body); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("body %q: status = %d, want 400", body, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
fs := newFakeSnippetStore()
|
||||
h := NewSnippetHandler(fs)
|
||||
rec := create(h, "", snippetBody("title", "content"))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if len(fs.snippets) != 0 {
|
||||
t.Fatal("nothing must be stored for an unauthenticated request")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store failure returns 500", func(t *testing.T) {
|
||||
fs := newFakeSnippetStore()
|
||||
fs.createErr = errors.New("db down")
|
||||
h := NewSnippetHandler(fs)
|
||||
rec := create(h, "u1", snippetBody("title", "content"))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSnippet(t *testing.T) {
|
||||
setup := func() (*SnippetHandler, *fakeSnippetStore) {
|
||||
fs := newFakeSnippetStore()
|
||||
fs.seed("s1", "Title One", "Content One", "u1")
|
||||
return NewSnippetHandler(fs), fs
|
||||
}
|
||||
get := func(h *SnippetHandler, target string) *httptest.ResponseRecorder {
|
||||
// Sengaja tanpa header Authorization sama sekali.
|
||||
rec := httptest.NewRecorder()
|
||||
h.Get(rec, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("existing id returns 200 without Authorization header", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := get(h, "/snippets/s1")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
if m["id"] != "s1" || m["title"] != "Title One" || m["content"] != "Content One" {
|
||||
t.Fatalf("unexpected body: %v", m)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("response has exactly the keys id, title, content", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := get(h, "/snippets/s1")
|
||||
assertKeys(t, decodeMap(t, rec.Body.Bytes()), "id", "title", "content")
|
||||
})
|
||||
|
||||
t.Run("missing id returns 404", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := get(h, "/snippets/does-not-exist")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty id returns 400", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := get(h, "/snippets/")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store failure returns 500", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.getErr = errors.New("db down")
|
||||
rec := get(h, "/snippets/s1")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListSnippets(t *testing.T) {
|
||||
setup := func() (*SnippetHandler, *fakeSnippetStore) {
|
||||
fs := newFakeSnippetStore()
|
||||
fs.seed("a1", "A one", "content a1", "alice")
|
||||
fs.seed("a2", "A two", "content a2", "alice")
|
||||
fs.seed("b1", "B one", "content b1", "bob")
|
||||
return NewSnippetHandler(fs), fs
|
||||
}
|
||||
list := func(h *SnippetHandler, userID string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/snippets", nil)
|
||||
if userID != "" {
|
||||
req = withUser(req, userID)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.List(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("only the caller's snippets are returned", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := list(h, "alice")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
var items []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("body is not a JSON array: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("got %d snippets, want 2", len(items))
|
||||
}
|
||||
for _, it := range items {
|
||||
if id := it["id"]; id != "a1" && id != "a2" {
|
||||
t.Fatalf("snippet %v does not belong to the caller", id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no snippets returns literally []", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := list(h, "carol")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if got := strings.TrimSpace(rec.Body.String()); got != "[]" {
|
||||
t.Fatalf("body = %q, want []", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := list(h, "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("each element has exactly the three keys", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := list(h, "alice")
|
||||
var items []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("body is not a JSON array: %v", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Fatal("expected at least one element")
|
||||
}
|
||||
for _, it := range items {
|
||||
assertKeys(t, it, "id", "title", "content")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store failure returns 500", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.listErr = errors.New("db down")
|
||||
rec := list(h, "alice")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteSnippet(t *testing.T) {
|
||||
setup := func() (*SnippetHandler, *fakeSnippetStore) {
|
||||
fs := newFakeSnippetStore()
|
||||
fs.seed("s1", "Title", "Content", "owner")
|
||||
return NewSnippetHandler(fs), fs
|
||||
}
|
||||
del := func(h *SnippetHandler, userID, id string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodDelete, "/snippets/"+id, nil)
|
||||
if userID != "" {
|
||||
req = withUser(req, userID)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.Delete(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("owner gets 204 with empty body and snippet is gone", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
rec := del(h, "owner", "s1")
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Fatalf("body must be empty, got %q", rec.Body.String())
|
||||
}
|
||||
if fs.has("s1") {
|
||||
t.Fatal("snippet must be removed from the store")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-owner gets 403 and snippet remains", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
rec := del(h, "intruder", "s1")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if !fs.has("s1") {
|
||||
t.Fatal("snippet must still exist after a forbidden delete")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent id returns 404", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := del(h, "owner", "nope")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
rec := del(h, "", "s1")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if !fs.has("s1") {
|
||||
t.Fatal("snippet must still exist")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-owner on a nonexistent id returns 404 not 403", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := del(h, "intruder", "nope")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty id returns 400", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
req := withUser(httptest.NewRequest(http.MethodDelete, "/snippets/", nil), "owner")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Delete(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lookup failure returns 500", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.getErr = errors.New("db down")
|
||||
rec := del(h, "owner", "s1")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete failure returns 500", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.deleteErr = errors.New("db down")
|
||||
rec := del(h, "owner", "s1")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("snippet removed between lookup and delete returns 404", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.deleteErr = errNotFoundForTest()
|
||||
rec := del(h, "owner", "s1")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user