This commit is contained in:
Yovan
2026-10-03 20:28:25 +07:00
parent 14283c2b61
commit 650b24619f
23 changed files with 2373 additions and 30 deletions
+24 -19
View File
@@ -8,17 +8,18 @@ import (
"time"
"5803025020/w04/internal/auth"
"5803025020/w04/internal/httpjson"
"5803025020/w04/internal/models"
"5803025020/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 {
func NewAuthHandler(userStore UserStore, sessionStore SessionStore) *AuthHandler {
return &AuthHandler{
userStore: userStore,
sessionStore: sessionStore,
@@ -32,25 +33,25 @@ type authRequest struct {
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
httpjson.WriteError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
return
}
var req authRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
httpjson.WriteError(w, http.StatusBadRequest, "Bad Request")
return
}
req.Username = strings.TrimSpace(req.Username)
if len(req.Username) < 3 || len(req.Username) > 32 || req.Password == "" {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
httpjson.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)
httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
@@ -62,10 +63,10 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
if err := h.userStore.Create(r.Context(), user); err != nil {
if errors.Is(err, store.ErrUsernameTaken) {
http.Error(w, "Username already exists", http.StatusConflict)
httpjson.WriteError(w, http.StatusConflict, "Username already exists")
return
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
@@ -76,25 +77,25 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
httpjson.WriteError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
return
}
var req authRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
httpjson.WriteError(w, http.StatusBadRequest, "Bad Request")
return
}
user, err := h.userStore.GetByUsername(r.Context(), req.Username)
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
httpjson.WriteError(w, http.StatusUnauthorized, "Invalid credentials")
return
}
token, err := auth.GenerateToken()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
@@ -105,7 +106,7 @@ 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)
httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
@@ -116,28 +117,32 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
httpjson.WriteError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
httpjson.WriteError(w, http.StatusUnauthorized, "Unauthorized")
return
}
token := authHeader
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token = authHeader[7:]
token = authHeader[len("Bearer "):]
}
token = strings.TrimSpace(token)
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
httpjson.WriteError(w, http.StatusUnauthorized, "Unauthorized")
return
}
if err := h.sessionStore.Delete(r.Context(), token); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
if errors.Is(err, store.ErrSessionNotFound) {
httpjson.WriteError(w, http.StatusUnauthorized, "Unauthorized")
return
}
httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
+265
View File
@@ -0,0 +1,265 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"5803025020/w04/internal/auth"
"5803025020/w04/internal/models"
)
func newTestAuthHandler() (*AuthHandler, *fakeUserStore, *fakeSessionStore) {
us := newFakeUserStore()
ss := newFakeSessionStore()
return NewAuthHandler(us, ss), us, ss
}
// doJSONRequest builds a request whose body is either a Go value (marshalled
// to JSON) or a raw string (used as-is, to exercise malformed-JSON cases).
func doJSONRequest(h http.HandlerFunc, method, path string, body any) *httptest.ResponseRecorder {
var raw []byte
switch v := body.(type) {
case string:
raw = []byte(v)
default:
raw, _ = json.Marshal(v)
}
req := httptest.NewRequest(method, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
// assertJSONContentType is used on every handler test subtest that inspects
// a non-204 response, per the spec's blanket requirement.
func assertJSONContentType(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
ct := rec.Header().Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
t.Fatalf("expected Content-Type application/json, got %q", ct)
}
}
func TestRegister(t *testing.T) {
t.Run("success returns 201 with id and echoed username", func(t *testing.T) {
h, _, _ := newTestAuthHandler()
rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{
"username": "alice", "password": "password123",
})
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
assertJSONContentType(t, rec)
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("expected JSON body: %v", err)
}
if id, _ := body["id"].(string); id == "" {
t.Fatal("expected non-empty id in response")
}
if body["username"] != "alice" {
t.Fatalf("expected username %q, got %v", "alice", body["username"])
}
})
t.Run("duplicate username returns 409", func(t *testing.T) {
h, _, _ := newTestAuthHandler()
doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "bob", "password": "password123"})
rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "bob", "password": "anotherpass"})
if rec.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", rec.Code, rec.Body.String())
}
assertJSONContentType(t, rec)
})
t.Run("empty username returns 400", func(t *testing.T) {
h, _, _ := newTestAuthHandler()
rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "", "password": "password123"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("empty password returns 400", func(t *testing.T) {
h, _, _ := newTestAuthHandler()
rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "charlie", "password": ""})
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("username of 2 characters returns 400", func(t *testing.T) {
h, _, _ := newTestAuthHandler()
rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "ab", "password": "password123"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("username of 33 characters returns 400", func(t *testing.T) {
h, _, _ := newTestAuthHandler()
longUsername := strings.Repeat("a", 33)
rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": longUsername, "password": "password123"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("malformed JSON returns 400", func(t *testing.T) {
h, _, _ := newTestAuthHandler()
rec := doJSONRequest(h.Register, http.MethodPost, "/register", "{not valid json")
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("success body contains no password or password_hash key, nor the plaintext", func(t *testing.T) {
h, _, _ := newTestAuthHandler()
rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "dana", "password": "supersecretpw"})
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", rec.Code)
}
assertJSONContentType(t, rec)
lower := strings.ToLower(rec.Body.String())
if strings.Contains(lower, "password") {
t.Fatalf("response body must not contain any password-related key: %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "supersecretpw") {
t.Fatal("response body must not contain the plaintext password")
}
})
}
func TestLogin(t *testing.T) {
h, us, _ := newTestAuthHandler()
ctx := context.Background()
hash, err := auth.HashPassword("correcthorse")
if err != nil {
t.Fatalf("failed to hash seed password: %v", err)
}
if err := us.Create(ctx, &models.User{ID: "user-1", Username: "eve", PasswordHash: hash}); err != nil {
t.Fatalf("failed to seed user: %v", err)
}
var wrongPasswordBody, unknownUserBody string
t.Run("success returns 200 with non-empty session_token", func(t *testing.T) {
rec := doJSONRequest(h.Login, http.MethodPost, "/login", map[string]string{"username": "eve", "password": "correcthorse"})
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
assertJSONContentType(t, rec)
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("expected JSON body: %v", err)
}
tok, _ := body["session_token"].(string)
if tok == "" {
t.Fatal("expected non-empty session_token")
}
})
t.Run("wrong password returns 401", func(t *testing.T) {
rec := doJSONRequest(h.Login, http.MethodPost, "/login", map[string]string{"username": "eve", "password": "wrongpass"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
assertJSONContentType(t, rec)
wrongPasswordBody = rec.Body.String()
})
t.Run("unknown username returns 401", func(t *testing.T) {
rec := doJSONRequest(h.Login, http.MethodPost, "/login", map[string]string{"username": "ghost", "password": "whatever"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
assertJSONContentType(t, rec)
unknownUserBody = rec.Body.String()
})
t.Run("401 bodies for wrong password and unknown user are byte-for-byte identical", func(t *testing.T) {
if wrongPasswordBody == "" || unknownUserBody == "" {
t.Fatal("prior subtests did not populate response bodies")
}
if wrongPasswordBody != unknownUserBody {
t.Fatalf("expected identical bodies, got %q vs %q", wrongPasswordBody, unknownUserBody)
}
})
t.Run("malformed JSON returns 400", func(t *testing.T) {
rec := doJSONRequest(h.Login, http.MethodPost, "/login", "{bad json")
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
}
func TestLogout(t *testing.T) {
h, _, ss := newTestAuthHandler()
ctx := context.Background()
validToken := "validtoken1234567890123456789012"
if err := ss.Create(ctx, &models.Session{Token: validToken, UserID: "user-1"}); err != nil {
t.Fatalf("failed to seed session: %v", err)
}
t.Run("valid token returns 204 with empty body and removes the session", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("Authorization", "Bearer "+validToken)
rec := httptest.NewRecorder()
h.Logout(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
}
// 204 is exempt from the Content-Type:application/json requirement.
if rec.Body.Len() != 0 {
t.Fatalf("expected empty body, got %q", rec.Body.String())
}
if ss.has(validToken) {
t.Fatal("expected session to be removed from the fake store")
}
})
t.Run("missing token returns 401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
rec := httptest.NewRecorder()
h.Logout(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("unknown token returns 401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("Authorization", "Bearer not-a-real-token-000000000000")
rec := httptest.NewRecorder()
h.Logout(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("second logout with the same token returns 401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("Authorization", "Bearer "+validToken)
rec := httptest.NewRecorder()
h.Logout(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 on second logout, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
}
+135
View File
@@ -0,0 +1,135 @@
package handler
import (
"context"
"sync"
"5803025020/w04/internal/models"
"5803025020/w04/internal/store"
)
// fakeUserStore is a hand-written in-memory implementation of UserStore.
type fakeUserStore struct {
mu sync.Mutex
byName map[string]*models.User
}
func newFakeUserStore() *fakeUserStore {
return &fakeUserStore{byName: map[string]*models.User{}}
}
func (f *fakeUserStore) Create(ctx context.Context, user *models.User) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, exists := f.byName[user.Username]; exists {
return store.ErrUsernameTaken
}
cp := *user
f.byName[user.Username] = &cp
return nil
}
func (f *fakeUserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
u, ok := f.byName[username]
if !ok {
return nil, store.ErrUserNotFound
}
cp := *u
return &cp, nil
}
// fakeSessionStore is a hand-written in-memory implementation of SessionStore.
type fakeSessionStore struct {
mu sync.Mutex
sessions map[string]*models.Session
}
func newFakeSessionStore() *fakeSessionStore {
return &fakeSessionStore{sessions: map[string]*models.Session{}}
}
func (f *fakeSessionStore) Create(ctx context.Context, session *models.Session) error {
f.mu.Lock()
defer f.mu.Unlock()
cp := *session
f.sessions[session.Token] = &cp
return nil
}
func (f *fakeSessionStore) Delete(ctx context.Context, token string) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.sessions[token]; !ok {
return store.ErrSessionNotFound
}
delete(f.sessions, token)
return nil
}
func (f *fakeSessionStore) has(token string) bool {
f.mu.Lock()
defer f.mu.Unlock()
_, ok := f.sessions[token]
return ok
}
// fakeSnippetStore is a hand-written in-memory implementation of SnippetStore.
type fakeSnippetStore struct {
mu sync.Mutex
snippets map[string]*models.Snippet
}
func newFakeSnippetStore() *fakeSnippetStore {
return &fakeSnippetStore{snippets: map[string]*models.Snippet{}}
}
func (f *fakeSnippetStore) Create(ctx context.Context, snippet *models.Snippet) error {
f.mu.Lock()
defer f.mu.Unlock()
cp := *snippet
f.snippets[snippet.ID] = &cp
return nil
}
func (f *fakeSnippetStore) GetByID(ctx context.Context, id string) (*models.Snippet, error) {
f.mu.Lock()
defer f.mu.Unlock()
sn, ok := f.snippets[id]
if !ok {
return nil, store.ErrSnippetNotFound
}
cp := *sn
return &cp, nil
}
func (f *fakeSnippetStore) GetByUserID(ctx context.Context, userID string) ([]*models.Snippet, error) {
f.mu.Lock()
defer f.mu.Unlock()
result := make([]*models.Snippet, 0)
for _, sn := range f.snippets {
if sn.UserID == userID {
cp := *sn
result = append(result, &cp)
}
}
return result, nil
}
func (f *fakeSnippetStore) Delete(ctx context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.snippets[id]; !ok {
return store.ErrSnippetNotFound
}
delete(f.snippets, id)
return nil
}
func (f *fakeSnippetStore) has(id string) bool {
f.mu.Lock()
defer f.mu.Unlock()
_, ok := f.snippets[id]
return ok
}
+28
View File
@@ -0,0 +1,28 @@
package handler
import (
"context"
"5803025020/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
Delete(ctx context.Context, token string) error
}
type SnippetStore interface {
Create(ctx context.Context, snippet *models.Snippet) error
GetByID(ctx context.Context, id string) (*models.Snippet, error)
GetByUserID(ctx context.Context, userID string) ([]*models.Snippet, error)
Delete(ctx context.Context, id string) error
}
+327
View File
@@ -0,0 +1,327 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"5803025020/w04/internal/middleware"
"5803025020/w04/internal/models"
)
func mustJSONBody(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
return b
}
// authedRequest builds a request that already carries an authenticated user
// id in its context, simulating what RequireAuth does before calling the
// handler. This lets snippet handler tests run without any middleware.
func authedRequest(method, path string, body []byte, userID string) *http.Request {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
ctx := context.WithValue(req.Context(), middleware.UserIDKey, userID)
return req.WithContext(ctx)
}
func TestCreateSnippet(t *testing.T) {
t.Run("success returns 201 with exactly id, title, content", func(t *testing.T) {
ss := newFakeSnippetStore()
h := NewSnippetHandler(ss)
req := authedRequest(http.MethodPost, "/snippets", mustJSONBody(t, map[string]string{"title": "hello", "content": "world"}), "user-1")
rec := httptest.NewRecorder()
h.Create(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
assertJSONContentType(t, rec)
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("expected JSON body: %v", err)
}
wantKeys := []string{"id", "title", "content"}
if len(body) != len(wantKeys) {
t.Fatalf("expected exactly keys %v, got %v", wantKeys, body)
}
for _, k := range wantKeys {
if _, ok := body[k]; !ok {
t.Fatalf("missing key %q in response: %v", k, body)
}
}
})
t.Run("empty title returns 400", func(t *testing.T) {
ss := newFakeSnippetStore()
h := NewSnippetHandler(ss)
req := authedRequest(http.MethodPost, "/snippets", mustJSONBody(t, map[string]string{"title": "", "content": "world"}), "user-1")
rec := httptest.NewRecorder()
h.Create(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("empty content returns 400", func(t *testing.T) {
ss := newFakeSnippetStore()
h := NewSnippetHandler(ss)
req := authedRequest(http.MethodPost, "/snippets", mustJSONBody(t, map[string]string{"title": "hello", "content": ""}), "user-1")
rec := httptest.NewRecorder()
h.Create(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("malformed JSON returns 400", func(t *testing.T) {
ss := newFakeSnippetStore()
h := NewSnippetHandler(ss)
req := authedRequest(http.MethodPost, "/snippets", []byte("{bad"), "user-1")
rec := httptest.NewRecorder()
h.Create(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("unauthenticated returns 401", func(t *testing.T) {
ss := newFakeSnippetStore()
h := NewSnippetHandler(ss)
req := httptest.NewRequest(http.MethodPost, "/snippets", bytes.NewReader(mustJSONBody(t, map[string]string{"title": "hello", "content": "world"})))
rec := httptest.NewRecorder()
h.Create(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
}
func TestGetSnippet(t *testing.T) {
newSeeded := func() (*SnippetHandler, *fakeSnippetStore) {
ss := newFakeSnippetStore()
_ = ss.Create(context.Background(), &models.Snippet{ID: "s1", Title: "hello", Content: "world", UserID: "owner-1"})
return NewSnippetHandler(ss), ss
}
t.Run("existing id returns 200 without any Authorization header", func(t *testing.T) {
h, _ := newSeeded()
req := httptest.NewRequest(http.MethodGet, "/snippets/s1", nil)
req.SetPathValue("id", "s1")
rec := httptest.NewRecorder()
h.Get(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
assertJSONContentType(t, rec)
})
t.Run("missing id returns 404", func(t *testing.T) {
h, _ := newSeeded()
req := httptest.NewRequest(http.MethodGet, "/snippets/does-not-exist", nil)
req.SetPathValue("id", "does-not-exist")
rec := httptest.NewRecorder()
h.Get(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("response has exactly the keys id, title, content", func(t *testing.T) {
h, _ := newSeeded()
req := httptest.NewRequest(http.MethodGet, "/snippets/s1", nil)
req.SetPathValue("id", "s1")
rec := httptest.NewRecorder()
h.Get(rec, req)
assertJSONContentType(t, rec)
var decoded map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("expected JSON body: %v", err)
}
wantKeys := []string{"id", "title", "content"}
if len(decoded) != len(wantKeys) {
t.Fatalf("expected exactly keys %v, got %v", wantKeys, decoded)
}
for _, k := range wantKeys {
if _, ok := decoded[k]; !ok {
t.Fatalf("missing key %q in response: %v", k, decoded)
}
}
})
}
func TestListSnippets(t *testing.T) {
t.Run("only the caller's snippets are returned", func(t *testing.T) {
ss := newFakeSnippetStore()
ctx := context.Background()
_ = ss.Create(ctx, &models.Snippet{ID: "s1", Title: "t1", Content: "c1", UserID: "user-1"})
_ = ss.Create(ctx, &models.Snippet{ID: "s2", Title: "t2", Content: "c2", UserID: "user-2"})
h := NewSnippetHandler(ss)
req := authedRequest(http.MethodGet, "/snippets", nil, "user-1")
rec := httptest.NewRecorder()
h.List(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
assertJSONContentType(t, rec)
var got []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("expected JSON array: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected exactly 1 snippet, got %d: %v", len(got), got)
}
if got[0]["id"] != "s1" {
t.Fatalf("expected snippet s1, got %v", got[0])
}
})
t.Run("no snippets returns a literal empty array, not null", func(t *testing.T) {
ss := newFakeSnippetStore()
h := NewSnippetHandler(ss)
req := authedRequest(http.MethodGet, "/snippets", nil, "user-with-nothing")
rec := httptest.NewRecorder()
h.List(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
assertJSONContentType(t, rec)
body := strings.TrimSpace(rec.Body.String())
if body != "[]" {
t.Fatalf("expected literal [], got %q", body)
}
})
t.Run("unauthenticated returns 401", func(t *testing.T) {
ss := newFakeSnippetStore()
h := NewSnippetHandler(ss)
req := httptest.NewRequest(http.MethodGet, "/snippets", nil)
rec := httptest.NewRecorder()
h.List(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("each element has exactly the three keys", func(t *testing.T) {
ss := newFakeSnippetStore()
_ = ss.Create(context.Background(), &models.Snippet{ID: "s1", Title: "t1", Content: "c1", UserID: "user-1"})
h := NewSnippetHandler(ss)
req := authedRequest(http.MethodGet, "/snippets", nil, "user-1")
rec := httptest.NewRecorder()
h.List(rec, req)
assertJSONContentType(t, rec)
var got []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("expected JSON array: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 element, got %d", len(got))
}
wantKeys := []string{"id", "title", "content"}
if len(got[0]) != len(wantKeys) {
t.Fatalf("expected exactly keys %v, got %v", wantKeys, got[0])
}
})
}
func TestDeleteSnippet(t *testing.T) {
newSeeded := func() (*SnippetHandler, *fakeSnippetStore) {
ss := newFakeSnippetStore()
_ = ss.Create(context.Background(), &models.Snippet{ID: "s1", Title: "t1", Content: "c1", UserID: "owner-1"})
return NewSnippetHandler(ss), ss
}
t.Run("owner deleting returns 204 with empty body and snippet is gone", func(t *testing.T) {
h, ss := newSeeded()
req := authedRequest(http.MethodDelete, "/snippets/s1", nil, "owner-1")
req.SetPathValue("id", "s1")
rec := httptest.NewRecorder()
h.Delete(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
}
// 204 is exempt from the Content-Type:application/json requirement.
if rec.Body.Len() != 0 {
t.Fatalf("expected empty body, got %q", rec.Body.String())
}
if ss.has("s1") {
t.Fatal("expected snippet to be removed from the fake store")
}
})
t.Run("non-owner returns 403 and snippet still present", func(t *testing.T) {
h, ss := newSeeded()
req := authedRequest(http.MethodDelete, "/snippets/s1", nil, "someone-else")
req.SetPathValue("id", "s1")
rec := httptest.NewRecorder()
h.Delete(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", rec.Code)
}
assertJSONContentType(t, rec)
if !ss.has("s1") {
t.Fatal("expected snippet to still be present")
}
})
t.Run("nonexistent id returns 404", func(t *testing.T) {
h, _ := newSeeded()
req := authedRequest(http.MethodDelete, "/snippets/does-not-exist", nil, "owner-1")
req.SetPathValue("id", "does-not-exist")
rec := httptest.NewRecorder()
h.Delete(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("unauthenticated returns 401", func(t *testing.T) {
h, _ := newSeeded()
req := httptest.NewRequest(http.MethodDelete, "/snippets/s1", nil)
req.SetPathValue("id", "s1")
rec := httptest.NewRecorder()
h.Delete(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("non-owner on a nonexistent id returns 404, not 403", func(t *testing.T) {
h, _ := newSeeded()
req := authedRequest(http.MethodDelete, "/snippets/does-not-exist", nil, "someone-else")
req.SetPathValue("id", "does-not-exist")
rec := httptest.NewRecorder()
h.Delete(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
assertJSONContentType(t, rec)
})
}