Assignment 4: tests, interface refactor, e2e suite
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func snippetBody(title, content string) string {
|
||||
b, _ := json.Marshal(map[string]string{"title": title, "content": content})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestCreateSnippet(t *testing.T) {
|
||||
create := func(h *SnippetHandler, userID, body string) *httptest.ResponseRecorder {
|
||||
req := newJSONRequest(http.MethodPost, "/snippets", body)
|
||||
if userID != "" {
|
||||
req = withUser(req, userID)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.Create(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("success returns 201 with exactly id, title, content", func(t *testing.T) {
|
||||
fs := newFakeSnippetStore()
|
||||
h := NewSnippetHandler(fs)
|
||||
rec := create(h, "u1", snippetBody("My title", "console.log('hi')"))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
assertKeys(t, m, "id", "title", "content")
|
||||
id, _ := m["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("id must be a non-empty string")
|
||||
}
|
||||
if m["title"] != "My title" || m["content"] != "console.log('hi')" {
|
||||
t.Fatalf("title/content not echoed correctly: %v", m)
|
||||
}
|
||||
if !fs.has(id) {
|
||||
t.Fatal("snippet must be stored")
|
||||
}
|
||||
if fs.snippets[id].UserID != "u1" {
|
||||
t.Fatalf("stored owner = %q, want u1", fs.snippets[id].UserID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty title returns 400", func(t *testing.T) {
|
||||
h := NewSnippetHandler(newFakeSnippetStore())
|
||||
rec := create(h, "u1", snippetBody("", "content"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty content returns 400", func(t *testing.T) {
|
||||
h := NewSnippetHandler(newFakeSnippetStore())
|
||||
rec := create(h, "u1", snippetBody("title", ""))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("malformed JSON returns 400", func(t *testing.T) {
|
||||
h := NewSnippetHandler(newFakeSnippetStore())
|
||||
rec := create(h, "u1", `{"title": "x", "content":`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty body and JSON array return 400", func(t *testing.T) {
|
||||
h := NewSnippetHandler(newFakeSnippetStore())
|
||||
for _, body := range []string{``, `["a","b"]`} {
|
||||
if rec := create(h, "u1", body); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("body %q: status = %d, want 400", body, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
fs := newFakeSnippetStore()
|
||||
h := NewSnippetHandler(fs)
|
||||
rec := create(h, "", snippetBody("title", "content"))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if len(fs.snippets) != 0 {
|
||||
t.Fatal("nothing must be stored for an unauthenticated request")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store failure returns 500", func(t *testing.T) {
|
||||
fs := newFakeSnippetStore()
|
||||
fs.createErr = errors.New("db down")
|
||||
h := NewSnippetHandler(fs)
|
||||
rec := create(h, "u1", snippetBody("title", "content"))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSnippet(t *testing.T) {
|
||||
setup := func() (*SnippetHandler, *fakeSnippetStore) {
|
||||
fs := newFakeSnippetStore()
|
||||
fs.seed("s1", "Title One", "Content One", "u1")
|
||||
return NewSnippetHandler(fs), fs
|
||||
}
|
||||
get := func(h *SnippetHandler, target string) *httptest.ResponseRecorder {
|
||||
// Sengaja tanpa header Authorization sama sekali.
|
||||
rec := httptest.NewRecorder()
|
||||
h.Get(rec, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("existing id returns 200 without Authorization header", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := get(h, "/snippets/s1")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
if m["id"] != "s1" || m["title"] != "Title One" || m["content"] != "Content One" {
|
||||
t.Fatalf("unexpected body: %v", m)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("response has exactly the keys id, title, content", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := get(h, "/snippets/s1")
|
||||
assertKeys(t, decodeMap(t, rec.Body.Bytes()), "id", "title", "content")
|
||||
})
|
||||
|
||||
t.Run("missing id returns 404", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := get(h, "/snippets/does-not-exist")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty id returns 400", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := get(h, "/snippets/")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store failure returns 500", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.getErr = errors.New("db down")
|
||||
rec := get(h, "/snippets/s1")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListSnippets(t *testing.T) {
|
||||
setup := func() (*SnippetHandler, *fakeSnippetStore) {
|
||||
fs := newFakeSnippetStore()
|
||||
fs.seed("a1", "A one", "content a1", "alice")
|
||||
fs.seed("a2", "A two", "content a2", "alice")
|
||||
fs.seed("b1", "B one", "content b1", "bob")
|
||||
return NewSnippetHandler(fs), fs
|
||||
}
|
||||
list := func(h *SnippetHandler, userID string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/snippets", nil)
|
||||
if userID != "" {
|
||||
req = withUser(req, userID)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.List(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("only the caller's snippets are returned", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := list(h, "alice")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
var items []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("body is not a JSON array: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("got %d snippets, want 2", len(items))
|
||||
}
|
||||
for _, it := range items {
|
||||
if id := it["id"]; id != "a1" && id != "a2" {
|
||||
t.Fatalf("snippet %v does not belong to the caller", id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no snippets returns literally []", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := list(h, "carol")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if got := strings.TrimSpace(rec.Body.String()); got != "[]" {
|
||||
t.Fatalf("body = %q, want []", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := list(h, "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("each element has exactly the three keys", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := list(h, "alice")
|
||||
var items []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("body is not a JSON array: %v", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Fatal("expected at least one element")
|
||||
}
|
||||
for _, it := range items {
|
||||
assertKeys(t, it, "id", "title", "content")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store failure returns 500", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.listErr = errors.New("db down")
|
||||
rec := list(h, "alice")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteSnippet(t *testing.T) {
|
||||
setup := func() (*SnippetHandler, *fakeSnippetStore) {
|
||||
fs := newFakeSnippetStore()
|
||||
fs.seed("s1", "Title", "Content", "owner")
|
||||
return NewSnippetHandler(fs), fs
|
||||
}
|
||||
del := func(h *SnippetHandler, userID, id string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodDelete, "/snippets/"+id, nil)
|
||||
if userID != "" {
|
||||
req = withUser(req, userID)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.Delete(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("owner gets 204 with empty body and snippet is gone", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
rec := del(h, "owner", "s1")
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Fatalf("body must be empty, got %q", rec.Body.String())
|
||||
}
|
||||
if fs.has("s1") {
|
||||
t.Fatal("snippet must be removed from the store")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-owner gets 403 and snippet remains", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
rec := del(h, "intruder", "s1")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if !fs.has("s1") {
|
||||
t.Fatal("snippet must still exist after a forbidden delete")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent id returns 404", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := del(h, "owner", "nope")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
rec := del(h, "", "s1")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if !fs.has("s1") {
|
||||
t.Fatal("snippet must still exist")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-owner on a nonexistent id returns 404 not 403", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := del(h, "intruder", "nope")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty id returns 400", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
req := withUser(httptest.NewRequest(http.MethodDelete, "/snippets/", nil), "owner")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Delete(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lookup failure returns 500", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.getErr = errors.New("db down")
|
||||
rec := del(h, "owner", "s1")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete failure returns 500", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.deleteErr = errors.New("db down")
|
||||
rec := del(h, "owner", "s1")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("snippet removed between lookup and delete returns 404", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.deleteErr = errNotFoundForTest()
|
||||
rec := del(h, "owner", "s1")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user