Assignment 4: add unit, integration, and e2e tests; refactor for testability

This commit is contained in:
angelicatesvara07-crypto
2026-10-03 19:09:40 +07:00
parent 4ac21847fc
commit 3945920abb
24 changed files with 2753 additions and 147 deletions
+70 -49
View File
@@ -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,
})
}
+292
View File
@@ -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)
})
}
+236
View File
@@ -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)
}
})
}
+194
View File
@@ -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
}
+25
View File
@@ -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
}
+69 -50
View File
@@ -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
}
+293
View File
@@ -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)
})
}