Assignment 4: add unit, integration, and e2e tests; refactor for testability
This commit is contained in:
@@ -2,26 +2,26 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"5803025015/w04/internal/auth"
|
||||
"5803025015/w04/internal/middleware"
|
||||
"5803025015/w04/internal/models"
|
||||
"5803025015/w04/internal/store"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
userStore *store.UserStore
|
||||
sessionStore *store.SessionStore
|
||||
userStore UserStore
|
||||
sessionStore SessionStore
|
||||
}
|
||||
|
||||
func NewAuthHandler(userStore *store.UserStore, sessionStore *store.SessionStore) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
userStore: userStore,
|
||||
sessionStore: sessionStore,
|
||||
}
|
||||
func NewAuthHandler(userStore UserStore, sessionStore SessionStore) *AuthHandler {
|
||||
return &AuthHandler{userStore: userStore, sessionStore: sessionStore}
|
||||
}
|
||||
|
||||
type authRequest struct {
|
||||
@@ -29,27 +29,38 @@ type authRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// ---------- shared JSON helpers (dipakai juga oleh snippet_handler.go) ----------
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// ---------- handlers ----------
|
||||
|
||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req authRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
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)
|
||||
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 error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,36 +71,36 @@ 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.ErrDuplicate) {
|
||||
writeError(w, http.StatusConflict, "username already exists")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(user)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
})
|
||||
}
|
||||
|
||||
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, "invalid request body")
|
||||
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 || user == nil || !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 error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -100,43 +111,53 @@ 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 error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(session)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"session_token": token,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
const prefix = "bearer "
|
||||
if !strings.HasPrefix(strings.ToLower(authHeader), prefix) {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(authHeader[len(prefix):])
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
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 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sessionStore.Delete(r.Context(), token); err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "internal 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)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := r.Context().Value(middleware.UserIDKey).(string)
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"message": "Berhasil mengambil profil",
|
||||
"user_id": userID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"5803025015/w04/internal/auth"
|
||||
"5803025015/w04/internal/models"
|
||||
)
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func setupAuthHandler() (*AuthHandler, *fakeUserStore, *fakeSessionStore) {
|
||||
us := newFakeUserStore()
|
||||
ss := newFakeSessionStore()
|
||||
return NewAuthHandler(us, ss), us, ss
|
||||
}
|
||||
|
||||
func doRequest(
|
||||
h http.HandlerFunc,
|
||||
method, path string,
|
||||
body []byte,
|
||||
headers map[string]string,
|
||||
) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
if headers != nil {
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func assertContentTypeJSON(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
if rec.Code == http.StatusNoContent {
|
||||
return
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Fatalf("want Content-Type application/json, got %q (status=%d, body=%q)",
|
||||
ct, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func decodeMap(t *testing.T, body []byte) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
t.Fatalf("body not valid JSON: %v; body=%q", err, string(body))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ---------- TestRegister ----------
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
t.Run("success -> 201 with id and username", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
rec := doRequest(h.Register, http.MethodPost, "/register",
|
||||
[]byte(`{"username":"alice","password":"secret123"}`), nil)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("want 201, got %d (body=%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
if m["id"] == nil || m["id"] == "" {
|
||||
t.Fatalf("missing id in response: %v", m)
|
||||
}
|
||||
if m["username"] != "alice" {
|
||||
t.Fatalf("want username alice, got %v", m["username"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate username -> 409", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
body := []byte(`{"username":"alice","password":"secret123"}`)
|
||||
_ = doRequest(h.Register, http.MethodPost, "/register", body, nil)
|
||||
rec := doRequest(h.Register, http.MethodPost, "/register", body, nil)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("want 409, got %d (body=%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty username -> 400", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
rec := doRequest(h.Register, http.MethodPost, "/register",
|
||||
[]byte(`{"username":"","password":"secret123"}`), nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty password -> 400", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
rec := doRequest(h.Register, http.MethodPost, "/register",
|
||||
[]byte(`{"username":"alice","password":""}`), nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("username of 2 chars -> 400", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
rec := doRequest(h.Register, http.MethodPost, "/register",
|
||||
[]byte(`{"username":"ab","password":"secret123"}`), nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("username of 33 chars -> 400", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
long := strings.Repeat("a", 33)
|
||||
rec := doRequest(h.Register, http.MethodPost, "/register",
|
||||
[]byte(`{"username":"`+long+`","password":"secret123"}`), nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON -> 400", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
rec := doRequest(h.Register, http.MethodPost, "/register",
|
||||
[]byte(`{"username":"alice"`), nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("success body has no password / password_hash key", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
rec := doRequest(h.Register, http.MethodPost, "/register",
|
||||
[]byte(`{"username":"alice","password":"secret123"}`), nil)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("want 201, got %d", rec.Code)
|
||||
}
|
||||
raw := rec.Body.String()
|
||||
if strings.Contains(raw, "password") {
|
||||
t.Fatalf("body should not contain 'password': %s", raw)
|
||||
}
|
||||
if strings.Contains(raw, "secret123") {
|
||||
t.Fatalf("body leaks plaintext password: %s", raw)
|
||||
}
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
if _, ok := m["password"]; ok {
|
||||
t.Fatal("response has 'password' key")
|
||||
}
|
||||
if _, ok := m["password_hash"]; ok {
|
||||
t.Fatal("response has 'password_hash' key")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- TestLogin ----------
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
t.Run("success -> 200 with non-empty session_token", func(t *testing.T) {
|
||||
h, us, _ := setupAuthHandler()
|
||||
hash, _ := auth.HashPassword("secret123")
|
||||
_ = us.Create(nil, &models.User{ID: "u1", Username: "alice", PasswordHash: hash})
|
||||
|
||||
rec := doRequest(h.Login, http.MethodPost, "/login",
|
||||
[]byte(`{"username":"alice","password":"secret123"}`), nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body=%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
tok, _ := m["session_token"].(string)
|
||||
if tok == "" {
|
||||
t.Fatalf("want non-empty session_token, got %v", m)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong password -> 401", func(t *testing.T) {
|
||||
h, us, _ := setupAuthHandler()
|
||||
hash, _ := auth.HashPassword("secret123")
|
||||
_ = us.Create(nil, &models.User{ID: "u1", Username: "alice", PasswordHash: hash})
|
||||
|
||||
rec := doRequest(h.Login, http.MethodPost, "/login",
|
||||
[]byte(`{"username":"alice","password":"wrong"}`), nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("unknown username -> 401", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
rec := doRequest(h.Login, http.MethodPost, "/login",
|
||||
[]byte(`{"username":"ghost","password":"whatever"}`), nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("wrong password and unknown user bodies are byte-for-byte identical", func(t *testing.T) {
|
||||
h, us, _ := setupAuthHandler()
|
||||
hash, _ := auth.HashPassword("secret123")
|
||||
_ = us.Create(nil, &models.User{ID: "u1", Username: "alice", PasswordHash: hash})
|
||||
|
||||
rec1 := doRequest(h.Login, http.MethodPost, "/login",
|
||||
[]byte(`{"username":"alice","password":"wrong"}`), nil)
|
||||
rec2 := doRequest(h.Login, http.MethodPost, "/login",
|
||||
[]byte(`{"username":"ghost","password":"whatever"}`), nil)
|
||||
|
||||
if rec1.Code != rec2.Code {
|
||||
t.Fatalf("status codes differ: %d vs %d", rec1.Code, rec2.Code)
|
||||
}
|
||||
if !bytes.Equal(rec1.Body.Bytes(), rec2.Body.Bytes()) {
|
||||
t.Fatalf("bodies differ:\n1=%q\n2=%q", rec1.Body.String(), rec2.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON -> 400", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
rec := doRequest(h.Login, http.MethodPost, "/login",
|
||||
[]byte(`{"username":"alice"`), nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- TestLogout ----------
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
t.Run("valid token -> 204 empty body, session removed", func(t *testing.T) {
|
||||
h, _, ss := setupAuthHandler()
|
||||
_ = ss.Create(nil, &models.Session{Token: "tok-1", UserID: "u1"})
|
||||
|
||||
rec := doRequest(h.Logout, http.MethodPost, "/logout", nil,
|
||||
map[string]string{"Authorization": "Bearer tok-1"})
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("want 204, got %d (body=%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Fatalf("want empty body, got %q", rec.Body.String())
|
||||
}
|
||||
if ss.HasToken("tok-1") {
|
||||
t.Fatal("session should have been deleted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing token -> 401", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
rec := doRequest(h.Logout, http.MethodPost, "/logout", nil, nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("unknown token -> 401", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
rec := doRequest(h.Logout, http.MethodPost, "/logout", nil,
|
||||
map[string]string{"Authorization": "Bearer ghost"})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("second logout with same token -> 401", func(t *testing.T) {
|
||||
h, _, ss := setupAuthHandler()
|
||||
_ = ss.Create(nil, &models.Session{Token: "tok-2", UserID: "u1"})
|
||||
|
||||
rec1 := doRequest(h.Logout, http.MethodPost, "/logout", nil,
|
||||
map[string]string{"Authorization": "Bearer tok-2"})
|
||||
if rec1.Code != http.StatusNoContent {
|
||||
t.Fatalf("first logout: want 204, got %d", rec1.Code)
|
||||
}
|
||||
rec2 := doRequest(h.Logout, http.MethodPost, "/logout", nil,
|
||||
map[string]string{"Authorization": "Bearer tok-2"})
|
||||
if rec2.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("second logout: want 401, got %d", rec2.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec2)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"5803025015/w04/internal/auth"
|
||||
"5803025015/w04/internal/middleware"
|
||||
"5803025015/w04/internal/models"
|
||||
)
|
||||
|
||||
// ---------- GetProfile ----------
|
||||
|
||||
func TestGetProfile(t *testing.T) {
|
||||
t.Run("with user id in context -> 200", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
req := httptest.NewRequest(http.MethodGet, "/profile", nil)
|
||||
ctx := context.WithValue(req.Context(), middleware.UserIDKey, "u1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.GetProfile(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("without user id -> 401", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
req := httptest.NewRequest(http.MethodGet, "/profile", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.GetProfile(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Method dispatch ----------
|
||||
|
||||
func TestSnippetsDispatch(t *testing.T) {
|
||||
t.Run("GET dispatch", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.Snippets, http.MethodGet, "/snippets", nil, "u1")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST dispatch", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.Snippets, http.MethodPost, "/snippets",
|
||||
[]byte(`{"title":"t","content":"c"}`), "u1")
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("want 201, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PUT -> 405", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.Snippets, http.MethodPut, "/snippets", nil, "u1")
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("want 405, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSnippetByIDDispatch(t *testing.T) {
|
||||
t.Run("GET dispatch", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
_ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"})
|
||||
rec := doSnippetRequest(h.SnippetByID, http.MethodGet, "/snippets/s1", nil, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DELETE dispatch", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
_ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"})
|
||||
rec := doSnippetRequest(h.SnippetByID, http.MethodDelete, "/snippets/s1", nil, "u1")
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("want 204, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PUT -> 405", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.SnippetByID, http.MethodPut, "/snippets/s1", nil, "u1")
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("want 405, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Auth handler error branches ----------
|
||||
|
||||
func TestAuthErrorBranches(t *testing.T) {
|
||||
t.Run("Register: store non-duplicate error -> 500", func(t *testing.T) {
|
||||
h, us, _ := setupAuthHandler()
|
||||
us.createErr = errors.New("boom")
|
||||
rec := doRequest(h.Register, http.MethodPost, "/register",
|
||||
[]byte(`{"username":"alice","password":"secret123"}`), nil)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("want 500, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("Login: session store create error -> 500", func(t *testing.T) {
|
||||
h, us, ss := setupAuthHandler()
|
||||
hash, _ := auth.HashPassword("secret123")
|
||||
_ = us.Create(nil, &models.User{ID: "u1", Username: "alice", PasswordHash: hash})
|
||||
ss.createErr = errors.New("boom")
|
||||
rec := doRequest(h.Login, http.MethodPost, "/login",
|
||||
[]byte(`{"username":"alice","password":"secret123"}`), nil)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("want 500, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("Logout: delete error -> 500", func(t *testing.T) {
|
||||
h, _, ss := setupAuthHandler()
|
||||
_ = ss.Create(nil, &models.Session{Token: "tok", UserID: "u1"})
|
||||
ss.deleteErr = errors.New("boom")
|
||||
rec := doRequest(h.Logout, http.MethodPost, "/logout", nil,
|
||||
map[string]string{"Authorization": "Bearer tok"})
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("want 500, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("Logout: Basic scheme -> 401", func(t *testing.T) {
|
||||
h, _, _ := setupAuthHandler()
|
||||
rec := doRequest(h.Logout, http.MethodPost, "/logout", nil,
|
||||
map[string]string{"Authorization": "Basic abc"})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Snippet handler error branches ----------
|
||||
|
||||
func TestSnippetErrorBranches(t *testing.T) {
|
||||
t.Run("Create: store error -> 500", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
store.createErr = errors.New("boom")
|
||||
rec := doSnippetRequest(h.Create, http.MethodPost, "/snippets",
|
||||
[]byte(`{"title":"t","content":"c"}`), "u1")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("want 500, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("List: store error -> 500", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
store.listErr = errors.New("boom")
|
||||
rec := doSnippetRequest(h.List, http.MethodGet, "/snippets", nil, "u1")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("want 500, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("Get: store non-NotFound error -> 500", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
store.getErr = errors.New("boom")
|
||||
rec := doSnippetRequest(h.Get, http.MethodGet, "/snippets/s1", nil, "")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("want 500, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("Delete: GetByID store error -> 500", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
store.getErr = errors.New("boom")
|
||||
rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/s1", nil, "u1")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("want 500, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("Delete: Delete store error -> 500", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
_ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"})
|
||||
store.deleteErr = errors.New("boom")
|
||||
rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/s1", nil, "u1")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("want 500, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("Delete: missing id -> 400", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/snippets/", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), middleware.UserIDKey, "u1"))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Delete(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- extractID PathValue branch ----------
|
||||
|
||||
func TestExtractIDWithPathValue(t *testing.T) {
|
||||
t.Run("uses PathValue when set", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/snippets/", nil)
|
||||
req.SetPathValue("id", "abc-123")
|
||||
if got := extractID(req); got != "abc-123" {
|
||||
t.Fatalf("want abc-123, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to path parsing", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/snippets/xyz/", nil)
|
||||
if got := extractID(req); got != "xyz" {
|
||||
t.Fatalf("want xyz, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"5803025015/w04/internal/models"
|
||||
"5803025015/w04/internal/store"
|
||||
)
|
||||
|
||||
// ---------- fakeUserStore ----------
|
||||
|
||||
type fakeUserStore struct {
|
||||
mu sync.Mutex
|
||||
users map[string]*models.User
|
||||
nextID int
|
||||
createErr error
|
||||
getErr error
|
||||
}
|
||||
|
||||
func newFakeUserStore() *fakeUserStore {
|
||||
return &fakeUserStore{users: make(map[string]*models.User)}
|
||||
}
|
||||
|
||||
func (f *fakeUserStore) Create(ctx context.Context, u *models.User) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
if _, exists := f.users[u.Username]; exists {
|
||||
return store.ErrDuplicate
|
||||
}
|
||||
if u.ID == "" {
|
||||
f.nextID++
|
||||
u.ID = "user-id"
|
||||
}
|
||||
cp := *u
|
||||
f.users[u.Username] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserStore) GetByUsername(ctx 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
|
||||
}
|
||||
|
||||
// ---------- fakeSessionStore ----------
|
||||
|
||||
type fakeSessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*models.Session
|
||||
createErr error
|
||||
getErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func newFakeSessionStore() *fakeSessionStore {
|
||||
return &fakeSessionStore{sessions: make(map[string]*models.Session)}
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) Create(ctx context.Context, s *models.Session) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
cp := *s
|
||||
if cp.CreatedAt.IsZero() {
|
||||
cp.CreatedAt = time.Now()
|
||||
}
|
||||
f.sessions[s.Token] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) Get(ctx 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(ctx context.Context, token string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.deleteErr != nil {
|
||||
return f.deleteErr
|
||||
}
|
||||
if _, ok := f.sessions[token]; !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
delete(f.sessions, token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) HasToken(token string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, ok := f.sessions[token]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ---------- fakeSnippetStore ----------
|
||||
|
||||
type fakeSnippetStore struct {
|
||||
mu sync.Mutex
|
||||
snippets map[string]*models.Snippet
|
||||
createErr error
|
||||
getErr error
|
||||
listErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func newFakeSnippetStore() *fakeSnippetStore {
|
||||
return &fakeSnippetStore{snippets: make(map[string]*models.Snippet)}
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) Create(ctx context.Context, s *models.Snippet) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
cp := *s
|
||||
f.snippets[s.ID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) GetByID(ctx 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
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) ListByOwner(ctx context.Context, ownerID string) ([]*models.Snippet, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
out := make([]*models.Snippet, 0)
|
||||
for _, s := range f.snippets {
|
||||
if s.UserID == ownerID {
|
||||
cp := *s
|
||||
out = append(out, &cp)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) Delete(ctx 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) HasSnippet(id string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, ok := f.snippets[id]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"5803025015/w04/internal/models"
|
||||
)
|
||||
|
||||
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, s *models.Snippet) error
|
||||
GetByID(ctx context.Context, id string) (*models.Snippet, error)
|
||||
ListByOwner(ctx context.Context, ownerID string) ([]*models.Snippet, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
@@ -9,15 +9,15 @@ import (
|
||||
"5803025015/w04/internal/middleware"
|
||||
"5803025015/w04/internal/models"
|
||||
"5803025015/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,6 +26,16 @@ type snippetRequest struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type snippetResponse struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func toSnippetResponse(s *models.Snippet) snippetResponse {
|
||||
return snippetResponse{ID: s.ID, Title: s.Title, Content: s.Content}
|
||||
}
|
||||
|
||||
func (h *SnippetHandler) Snippets(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
@@ -33,7 +43,7 @@ func (h *SnippetHandler) Snippets(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodPost:
|
||||
h.Create(w, r)
|
||||
default:
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,32 +54,27 @@ func (h *SnippetHandler) SnippetByID(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodDelete:
|
||||
h.Delete(w, r)
|
||||
default:
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
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, "invalid request body")
|
||||
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,85 +86,99 @@ 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 error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(snippet)
|
||||
writeJSON(w, http.StatusCreated, toSnippetResponse(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.ListByOwner(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(snippets)
|
||||
out := make([]snippetResponse, 0, len(snippets))
|
||||
for _, s := range snippets {
|
||||
out = append(out, toSnippetResponse(s))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
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 := extractID(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, "missing id")
|
||||
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 error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(snippet)
|
||||
writeJSON(w, http.StatusOK, toSnippetResponse(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 := extractID(r)
|
||||
if id == "" {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
|
||||
id = strings.TrimSuffix(path, "/")
|
||||
writeError(w, http.StatusBadRequest, "missing id")
|
||||
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 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 error")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func extractID(r *http.Request) string {
|
||||
if id := r.PathValue("id"); id != "" {
|
||||
return id
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"5803025015/w04/internal/middleware"
|
||||
"5803025015/w04/internal/models"
|
||||
)
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func setupSnippetHandler() (*SnippetHandler, *fakeSnippetStore) {
|
||||
ss := newFakeSnippetStore()
|
||||
return NewSnippetHandler(ss), ss
|
||||
}
|
||||
|
||||
// withUserID injects user_id into request context (simulating middleware).
|
||||
func withUserID(r *http.Request, userID string) *http.Request {
|
||||
ctx := context.WithValue(r.Context(), middleware.UserIDKey, userID)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
// doSnippetRequest fires handler with optional user_id injected.
|
||||
func doSnippetRequest(
|
||||
h http.HandlerFunc,
|
||||
method, path string,
|
||||
body []byte,
|
||||
userID string,
|
||||
) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
if userID != "" {
|
||||
req = withUserID(req, userID)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// decodeSnippetMap decodes into map[string]any to check EXACT key set.
|
||||
func decodeSnippetMap(t *testing.T, body []byte) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
t.Fatalf("body not valid JSON: %v; body=%q", err, string(body))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ---------- TestCreateSnippet ----------
|
||||
|
||||
func TestCreateSnippet(t *testing.T) {
|
||||
t.Run("success -> 201 with exactly id, title, content", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.Create, http.MethodPost, "/snippets",
|
||||
[]byte(`{"title":"hello","content":"world"}`), "u1")
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("want 201, got %d (body=%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
m := decodeSnippetMap(t, rec.Body.Bytes())
|
||||
if len(m) != 3 {
|
||||
t.Fatalf("want exactly 3 keys, got %d: %v", len(m), m)
|
||||
}
|
||||
for _, k := range []string{"id", "title", "content"} {
|
||||
if _, ok := m[k]; !ok {
|
||||
t.Fatalf("missing key %q in %v", k, m)
|
||||
}
|
||||
}
|
||||
if _, extra := m["user_id"]; extra {
|
||||
t.Fatal("response should not have user_id key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty title -> 400", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.Create, http.MethodPost, "/snippets",
|
||||
[]byte(`{"title":"","content":"world"}`), "u1")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty content -> 400", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.Create, http.MethodPost, "/snippets",
|
||||
[]byte(`{"title":"hello","content":""}`), "u1")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("malformed JSON -> 400", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.Create, http.MethodPost, "/snippets",
|
||||
[]byte(`{"title":"hello"`), "u1")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("unauthenticated -> 401", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.Create, http.MethodPost, "/snippets",
|
||||
[]byte(`{"title":"hello","content":"world"}`), "") // no user_id
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- TestGetSnippet ----------
|
||||
|
||||
func TestGetSnippet(t *testing.T) {
|
||||
t.Run("existing id -> 200 without Authorization header", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
_ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "owner"})
|
||||
|
||||
// PathValue needs Go 1.22; simulate via SetPathValue on the request
|
||||
rec := doSnippetRequest(h.Get, http.MethodGet, "/snippets/s1", nil, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body=%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("missing id -> 404", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.Get, http.MethodGet, "/snippets/ghost", nil, "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("response has exactly id, title, content keys", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
_ = store.Create(nil, &models.Snippet{ID: "s2", Title: "t", Content: "c", UserID: "owner"})
|
||||
|
||||
rec := doSnippetRequest(h.Get, http.MethodGet, "/snippets/s2", nil, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", rec.Code)
|
||||
}
|
||||
m := decodeSnippetMap(t, rec.Body.Bytes())
|
||||
if len(m) != 3 {
|
||||
t.Fatalf("want exactly 3 keys, got %d: %v", len(m), m)
|
||||
}
|
||||
for _, k := range []string{"id", "title", "content"} {
|
||||
if _, ok := m[k]; !ok {
|
||||
t.Fatalf("missing key %q", k)
|
||||
}
|
||||
}
|
||||
if _, extra := m["user_id"]; extra {
|
||||
t.Fatal("response should not have user_id key")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- TestListSnippets ----------
|
||||
|
||||
func TestListSnippets(t *testing.T) {
|
||||
t.Run("only caller's snippets returned (seed two users)", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
_ = store.Create(nil, &models.Snippet{ID: "s1", Title: "a", Content: "1", UserID: "u1"})
|
||||
_ = store.Create(nil, &models.Snippet{ID: "s2", Title: "b", Content: "2", UserID: "u2"})
|
||||
_ = store.Create(nil, &models.Snippet{ID: "s3", Title: "c", Content: "3", UserID: "u1"})
|
||||
|
||||
rec := doSnippetRequest(h.List, http.MethodGet, "/snippets", nil, "u1")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", rec.Code)
|
||||
}
|
||||
var arr []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &arr); err != nil {
|
||||
t.Fatalf("body not valid JSON array: %v", err)
|
||||
}
|
||||
if len(arr) != 2 {
|
||||
t.Fatalf("want 2 snippets for u1, got %d", len(arr))
|
||||
}
|
||||
for _, item := range arr {
|
||||
if _, ok := item["user_id"]; ok {
|
||||
t.Fatal("list response should not have user_id key")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no snippets -> body is literally []", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.List, http.MethodGet, "/snippets", nil, "empty-user")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", rec.Code)
|
||||
}
|
||||
raw := bytes.TrimSpace(rec.Body.Bytes())
|
||||
if string(raw) != "[]" {
|
||||
t.Fatalf("want literal [], got %q", string(raw))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated -> 401", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.List, http.MethodGet, "/snippets", nil, "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("each element has exactly 3 keys", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
_ = store.Create(nil, &models.Snippet{ID: "s1", Title: "a", Content: "1", UserID: "u1"})
|
||||
|
||||
rec := doSnippetRequest(h.List, http.MethodGet, "/snippets", nil, "u1")
|
||||
var arr []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &arr); err != nil {
|
||||
t.Fatalf("body not JSON array: %v", err)
|
||||
}
|
||||
if len(arr) != 1 {
|
||||
t.Fatalf("want 1, got %d", len(arr))
|
||||
}
|
||||
if len(arr[0]) != 3 {
|
||||
t.Fatalf("want 3 keys per element, got %d: %v", len(arr[0]), arr[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- TestDeleteSnippet ----------
|
||||
|
||||
func TestDeleteSnippet(t *testing.T) {
|
||||
t.Run("owner -> 204 empty body, snippet removed", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
_ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"})
|
||||
|
||||
rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/s1", nil, "u1")
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("want 204, got %d (body=%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Fatalf("want empty body, got %q", rec.Body.String())
|
||||
}
|
||||
if store.HasSnippet("s1") {
|
||||
t.Fatal("snippet should be gone")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-owner -> 403, snippet still present", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
_ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "owner"})
|
||||
|
||||
rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/s1", nil, "other")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("want 403, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
if !store.HasSnippet("s1") {
|
||||
t.Fatal("snippet should still exist")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent id -> 404", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/ghost", nil, "u1")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("unauthenticated -> 401", func(t *testing.T) {
|
||||
h, store := setupSnippetHandler()
|
||||
_ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "owner"})
|
||||
rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/s1", nil, "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
|
||||
t.Run("non-owner on nonexistent id -> 404 (not 403)", func(t *testing.T) {
|
||||
h, _ := setupSnippetHandler()
|
||||
rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/ghost", nil, "someone")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", rec.Code)
|
||||
}
|
||||
assertContentTypeJSON(t, rec)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user