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