Assignment 4: tests, interface refactor, e2e suite

This commit is contained in:
Zagar Nashrullah
2026-10-03 19:35:30 +07:00
parent 6e7e35d9a5
commit 004bf4e292
25 changed files with 2644 additions and 151 deletions
+45 -48
View File
@@ -2,22 +2,28 @@ package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"unicode/utf8"
"5803025048/W04/internal/auth"
"5803025048/W04/internal/middleware"
"5803025048/W04/internal/models"
"5803025048/W04/internal/store"
"github.com/google/uuid"
)
// bcrypt menolak password lebih dari 72 byte.
const maxPasswordBytes = 72
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,
@@ -30,26 +36,22 @@ 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)
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, "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)
n := utf8.RuneCountInString(req.Username)
if n < 3 || n > 32 || req.Password == "" || len(req.Password) > maxPasswordBytes {
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 Server Error")
return
}
@@ -60,36 +62,41 @@ 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.ErrUsernameTaken) {
writeError(w, http.StatusConflict, "Username already exists")
return
}
writeError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(user)
writeJSON(w, http.StatusCreated, user)
}
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, "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)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusUnauthorized, "Invalid credentials")
return
}
writeError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
if !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 Server Error")
return
}
@@ -100,43 +107,33 @@ 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 Server Error")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(session)
writeJSON(w, http.StatusOK, map[string]string{"session_token": session.Token})
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
token, ok := middleware.BearerToken(r)
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
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 {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
writeError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
if err := h.sessionStore.Delete(r.Context(), token); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
writeError(w, http.StatusInternalServerError, "Internal Server 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)
}
+320
View File
@@ -0,0 +1,320 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"5803025048/W04/internal/auth"
"5803025048/W04/internal/models"
)
func credsBody(username, password string) string {
b, _ := json.Marshal(map[string]string{"username": username, "password": password})
return string(b)
}
func TestRegister(t *testing.T) {
const password = "s3cret-pass-xyz"
newHandler := func() (*AuthHandler, *fakeUserStore) {
us := newFakeUserStore()
return NewAuthHandler(us, newFakeSessionStore()), us
}
register := func(h *AuthHandler, body string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h.Register(rec, newJSONRequest(http.MethodPost, "/register", body))
return rec
}
t.Run("success returns 201 with id and username", func(t *testing.T) {
h, _ := newHandler()
rec := register(h, credsBody("alice", password))
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201", rec.Code)
}
assertJSONContentType(t, rec)
m := decodeMap(t, rec.Body.Bytes())
if id, ok := m["id"].(string); !ok || id == "" {
t.Fatalf("id must be a non-empty string, got %v", m["id"])
}
if m["username"] != "alice" {
t.Fatalf("username = %v, want alice", m["username"])
}
})
t.Run("duplicate username returns 409", func(t *testing.T) {
h, _ := newHandler()
if rec := register(h, credsBody("alice", password)); rec.Code != http.StatusCreated {
t.Fatalf("first register status = %d, want 201", rec.Code)
}
rec := register(h, credsBody("alice", password))
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", rec.Code)
}
assertJSONContentType(t, rec)
})
badCases := []struct {
name string
body string
}{
{"empty username returns 400", credsBody("", password)},
{"empty password returns 400", credsBody("alice", "")},
{"username of 2 characters returns 400", credsBody("ab", password)},
{"username of 33 characters returns 400", credsBody(strings.Repeat("a", 33), password)},
{"malformed JSON returns 400", `{"username": "alice", "password":`},
{"empty body returns 400", ``},
{"JSON array body returns 400", `["alice","pw"]`},
{"password longer than bcrypt limit returns 400", credsBody("alice", strings.Repeat("p", 73))},
}
for _, tc := range badCases {
t.Run(tc.name, func(t *testing.T) {
h, us := newHandler()
rec := register(h, tc.body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
assertJSONContentType(t, rec)
if len(us.users) != 0 {
t.Fatal("no user must be stored for an invalid request")
}
})
}
t.Run("usernames of exactly 3 and 32 characters are accepted", func(t *testing.T) {
h, _ := newHandler()
for _, name := range []string{"abc", strings.Repeat("b", 32)} {
if rec := register(h, credsBody(name, password)); rec.Code != http.StatusCreated {
t.Fatalf("username %q: status = %d, want 201", name, rec.Code)
}
}
})
t.Run("success body contains no password material", func(t *testing.T) {
h, _ := newHandler()
rec := register(h, credsBody("alice", password))
m := decodeMap(t, rec.Body.Bytes())
for k := range m {
if strings.Contains(strings.ToLower(k), "password") {
t.Fatalf("response must not contain a password-like key, found %q", k)
}
}
if strings.Contains(rec.Body.String(), password) {
t.Fatal("response must not contain the plaintext password")
}
})
t.Run("store failure returns 500", func(t *testing.T) {
h, us := newHandler()
us.createErr = errors.New("db down")
rec := register(h, credsBody("alice", password))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
assertJSONContentType(t, rec)
})
}
func TestLogin(t *testing.T) {
const username = "alice"
const password = "correct-horse"
setup := func(t *testing.T) (*AuthHandler, *fakeUserStore, *fakeSessionStore) {
t.Helper()
us, ss := newFakeUserStore(), newFakeSessionStore()
hash, err := auth.HashPassword(password)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if err := us.Create(context.Background(), &models.User{ID: "u1", Username: username, PasswordHash: hash}); err != nil {
t.Fatalf("seed user: %v", err)
}
return NewAuthHandler(us, ss), us, ss
}
login := func(h *AuthHandler, body string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h.Login(rec, newJSONRequest(http.MethodPost, "/login", body))
return rec
}
t.Run("success returns 200 with session_token", func(t *testing.T) {
h, _, ss := setup(t)
rec := login(h, credsBody(username, password))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
assertJSONContentType(t, rec)
m := decodeMap(t, rec.Body.Bytes())
tok, ok := m["session_token"].(string)
if !ok || tok == "" {
t.Fatalf("session_token must be a non-empty string, got %v", m["session_token"])
}
if !ss.has(tok) {
t.Fatal("the returned token must be stored in the session store")
}
})
t.Run("wrong password returns 401", func(t *testing.T) {
h, _, _ := setup(t)
rec := login(h, credsBody(username, "wrong-password"))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("unknown username returns 401", func(t *testing.T) {
h, _, _ := setup(t)
rec := login(h, credsBody("nobody", password))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("401 bodies for wrong password and unknown user are identical", func(t *testing.T) {
h, _, _ := setup(t)
wrong := login(h, credsBody(username, "wrong-password"))
unknown := login(h, credsBody("nobody", password))
if wrong.Code != http.StatusUnauthorized || unknown.Code != http.StatusUnauthorized {
t.Fatalf("statuses = %d and %d, want 401 and 401", wrong.Code, unknown.Code)
}
if wrong.Body.String() != unknown.Body.String() {
t.Fatalf("bodies differ:\n wrong password: %q\n unknown user: %q", wrong.Body.String(), unknown.Body.String())
}
})
t.Run("malformed JSON returns 400", func(t *testing.T) {
h, _, _ := setup(t)
rec := login(h, `{"username":`)
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, _, _ := setup(t)
for _, body := range []string{``, `[1,2,3]`} {
if rec := login(h, body); rec.Code != http.StatusBadRequest {
t.Fatalf("body %q: status = %d, want 400", body, rec.Code)
}
}
})
t.Run("user store failure returns 500", func(t *testing.T) {
h, us, _ := setup(t)
us.getErr = errors.New("db down")
rec := login(h, credsBody(username, password))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
})
t.Run("session store failure returns 500", func(t *testing.T) {
h, _, ss := setup(t)
ss.createErr = errors.New("db down")
rec := login(h, credsBody(username, password))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
})
}
func TestLogout(t *testing.T) {
setup := func() (*AuthHandler, *fakeSessionStore) {
ss := newFakeSessionStore()
ss.sessions["tok-valid"] = &models.Session{Token: "tok-valid", UserID: "u1"}
ss.sessions["tok-other"] = &models.Session{Token: "tok-other", UserID: "u2"}
return NewAuthHandler(newFakeUserStore(), ss), ss
}
logout := func(h *AuthHandler, authz string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
if authz != "" {
req.Header.Set("Authorization", authz)
}
rec := httptest.NewRecorder()
h.Logout(rec, req)
return rec
}
t.Run("valid token returns 204 and removes the session", func(t *testing.T) {
h, ss := setup()
rec := logout(h, "Bearer tok-valid")
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 ss.has("tok-valid") {
t.Fatal("session store must no longer contain the token")
}
if !ss.has("tok-other") {
t.Fatal("other sessions must not be deleted")
}
})
t.Run("missing token returns 401", func(t *testing.T) {
h, _ := setup()
rec := logout(h, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("unknown token returns 401", func(t *testing.T) {
h, _ := setup()
rec := logout(h, "Bearer not-a-real-token")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
assertJSONContentType(t, rec)
})
t.Run("second logout with the same token returns 401", func(t *testing.T) {
h, _ := setup()
if rec := logout(h, "Bearer tok-valid"); rec.Code != http.StatusNoContent {
t.Fatalf("first logout status = %d, want 204", rec.Code)
}
rec := logout(h, "Bearer tok-valid")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("second logout status = %d, want 401", rec.Code)
}
})
t.Run("Basic scheme returns 401 and keeps the session", func(t *testing.T) {
h, ss := setup()
rec := logout(h, "Basic tok-valid")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if !ss.has("tok-valid") {
t.Fatal("session must not be deleted by a non-Bearer header")
}
})
t.Run("session lookup failure returns 500", func(t *testing.T) {
h, ss := setup()
ss.getErr = errors.New("db down")
rec := logout(h, "Bearer tok-valid")
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
})
t.Run("session delete failure returns 500", func(t *testing.T) {
h, ss := setup()
ss.deleteErr = errors.New("db down")
rec := logout(h, "Bearer tok-valid")
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
})
}
+237
View File
@@ -0,0 +1,237 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"sort"
"strings"
"sync"
"testing"
"5803025048/W04/internal/middleware"
"5803025048/W04/internal/models"
"5803025048/W04/internal/store"
)
// ---------- fake stores (in-memory, buatan tangan) ----------
type fakeUserStore struct {
mu sync.Mutex
users map[string]*models.User // key: username
createErr error
getErr error
}
func newFakeUserStore() *fakeUserStore {
return &fakeUserStore{users: map[string]*models.User{}}
}
func (f *fakeUserStore) Create(_ context.Context, user *models.User) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.createErr != nil {
return f.createErr
}
if _, exists := f.users[user.Username]; exists {
return store.ErrUsernameTaken
}
cp := *user
f.users[user.Username] = &cp
return nil
}
func (f *fakeUserStore) GetByUsername(_ 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
}
type fakeSessionStore struct {
mu sync.Mutex
sessions map[string]*models.Session // key: token
createErr error
getErr error
deleteErr error
}
func newFakeSessionStore() *fakeSessionStore {
return &fakeSessionStore{sessions: map[string]*models.Session{}}
}
func (f *fakeSessionStore) Create(_ context.Context, session *models.Session) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.createErr != nil {
return f.createErr
}
cp := *session
f.sessions[session.Token] = &cp
return nil
}
func (f *fakeSessionStore) Get(_ 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(_ context.Context, token string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.deleteErr != nil {
return f.deleteErr
}
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
}
type fakeSnippetStore struct {
mu sync.Mutex
snippets map[string]*models.Snippet // key: id
createErr error
getErr error
listErr error
deleteErr error
}
func newFakeSnippetStore() *fakeSnippetStore {
return &fakeSnippetStore{snippets: map[string]*models.Snippet{}}
}
func (f *fakeSnippetStore) seed(id, title, content, userID string) {
f.mu.Lock()
defer f.mu.Unlock()
f.snippets[id] = &models.Snippet{ID: id, Title: title, Content: content, UserID: userID}
}
func (f *fakeSnippetStore) Create(_ context.Context, snippet *models.Snippet) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.createErr != nil {
return f.createErr
}
cp := *snippet
f.snippets[snippet.ID] = &cp
return nil
}
func (f *fakeSnippetStore) GetByID(_ 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
}
// GetByUserID sengaja mengembalikan nil (bukan slice kosong) jika tidak ada hasil,
// supaya handler terbukti menghasilkan "[]" dan bukan "null".
func (f *fakeSnippetStore) GetByUserID(_ context.Context, userID string) ([]*models.Snippet, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.listErr != nil {
return nil, f.listErr
}
var out []*models.Snippet
for _, s := range f.snippets {
if s.UserID == userID {
cp := *s
out = append(out, &cp)
}
}
return out, nil
}
func (f *fakeSnippetStore) Delete(_ 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) has(id string) bool {
f.mu.Lock()
defer f.mu.Unlock()
_, ok := f.snippets[id]
return ok
}
// ---------- helper tes ----------
func newJSONRequest(method, target, body string) *http.Request {
req := httptest.NewRequest(method, target, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
return req
}
func withUser(req *http.Request, userID string) *http.Request {
return req.WithContext(context.WithValue(req.Context(), middleware.UserIDKey, userID))
}
func assertJSONContentType(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("Content-Type = %q, want application/json", ct)
}
}
func decodeMap(t *testing.T, raw []byte) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("body is not a JSON object: %v (body=%q)", err, string(raw))
}
return m
}
func assertKeys(t *testing.T, m map[string]any, want ...string) {
t.Helper()
got := make([]string, 0, len(m))
for k := range m {
got = append(got, k)
}
sort.Strings(got)
sort.Strings(want)
if !reflect.DeepEqual(got, want) {
t.Fatalf("keys = %v, want exactly %v", got, want)
}
}
func errNotFoundForTest() error { return store.ErrNotFound }
+28
View File
@@ -0,0 +1,28 @@
package handler
import (
"context"
"5803025048/W04/internal/models"
)
// Interface didefinisikan di package consumer (handler), bukan di store.
// Tipe konkret di package store memenuhi interface ini tanpa meng-import handler.
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, 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
}
+17
View File
@@ -0,0 +1,17 @@
package handler
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeError selalu mengirim error sebagai JSON (Content-Type: application/json).
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
+52 -67
View File
@@ -10,14 +10,13 @@ import (
"5803025048/W04/internal/models"
"5803025048/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,50 +25,32 @@ type snippetRequest struct {
Content string `json:"content"`
}
func (h *SnippetHandler) Snippets(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.List(w, r)
case http.MethodPost:
h.Create(w, r)
default:
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}
func (h *SnippetHandler) SnippetByID(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.Get(w, r)
case http.MethodDelete:
h.Delete(w, r)
default:
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
func snippetID(r *http.Request) string {
id := r.PathValue("id")
if id == "" {
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
id = strings.TrimSuffix(path, "/")
}
return id
}
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, "Bad Request")
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,83 +62,87 @@ 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 Server Error")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(snippet)
writeJSON(w, http.StatusCreated, 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.GetByUserID(r.Context(), userID)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
writeError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
if snippets == nil {
// Pastikan selalu "[]", bukan "null".
snippets = []*models.Snippet{}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(snippets)
writeJSON(w, http.StatusOK, snippets)
}
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 := snippetID(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, "Bad Request")
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 Server Error")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(snippet)
writeJSON(w, http.StatusOK, 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 := snippetID(r)
if id == "" {
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
id = strings.TrimSuffix(path, "/")
writeError(w, http.StatusBadRequest, "Bad Request")
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 Server 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 Server Error")
return
}
+364
View File
@@ -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)
}
})
}