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
+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)
})
}