Assignment 4: add unit, integration, and e2e tests; refactor for testability
This commit is contained in:
@@ -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