package web import ( "context" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "github.com/go-chi/chi/v5" "hearthforge/internal/config" "hearthforge/internal/db" "hearthforge/internal/gitcmd" "hearthforge/internal/markdown" ) // issueTestServer returns a router, the server and the admin session user. func issueTestServer(t *testing.T) (http.Handler, *Server, *db.SessionUser) { t.Helper() ctx := context.Background() dir := t.TempDir() d, err := db.Open(filepath.Join(dir, "hearthforge.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) if _, err := d.InitAdmin(ctx, "hunter2"); err != nil { t.Fatal(err) } admin, err := d.UserByName(ctx, db.AdminUsername) if err != nil || admin == nil { t.Fatalf("admin lookup failed: %v", err) } if _, err := d.CreateRepo(ctx, "demo", nil, false, "main", db.NowISO()); err != nil { t.Fatal(err) } cfg := &config.Config{ DataDir: dir, OwnerDisplayName: "Admin", MaxTitleBytes: 500, MaxTextBodyBytes: 100000, RateLimitDisabled: true, } s := &Server{Cfg: cfg, DB: d, MD: markdown.New(), Git: gitcmd.New(cfg)} // Repo routes 404 when the git directory is missing. if err := s.Git.Init(ctx, "demo", "main"); err != nil { t.Fatal(err) } r := chi.NewRouter() s.issueRoutes(r) return r, s, &db.SessionUser{ID: admin.ID, Username: db.AdminUsername, IsAdmin: true} } // do runs a request as the given user. Pass nil form values for a GET. func do(t *testing.T, h http.Handler, user *db.SessionUser, method, target string, form url.Values, ) *httptest.ResponseRecorder { t.Helper() var req *http.Request if form == nil { req = httptest.NewRequest(method, target, nil) } else { req = httptest.NewRequest(method, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } if user != nil { req = req.WithContext(context.WithValue(req.Context(), userKey, user)) } w := httptest.NewRecorder() h.ServeHTTP(w, req) return w } func TestIssueFlow(t *testing.T) { h, _, admin := issueTestServer(t) res := do(t, h, nil, "GET", "/demo/issues", nil) if res.Code != http.StatusOK { t.Fatalf("list status = %d", res.Code) } if !strings.Contains(res.Body.String(), "No open issues.") { t.Error("empty list did not render the empty state") } res = do(t, h, admin, "POST", "/demo/issues", url.Values{ "title": {"First bug"}, "body": {"It **breaks**."}, }) if res.Code != http.StatusFound { t.Fatalf("create status = %d, body %s", res.Code, res.Body.String()) } if got := res.Header().Get("Location"); got != "/demo/issues/1" { t.Fatalf("create redirected to %q", got) } res = do(t, h, admin, "GET", "/demo/issues/1", nil) body := res.Body.String() if res.Code != http.StatusOK { t.Fatalf("detail status = %d", res.Code) } if !strings.Contains(body, "First bug") { t.Error("detail did not show the title") } if !strings.Contains(body, "breaks") { t.Error("detail did not render the markdown body") } res = do(t, h, admin, "POST", "/demo/issues/1/comments", url.Values{"body": {"Confirmed."}}) if res.Code != http.StatusFound { t.Fatalf("comment status = %d", res.Code) } res = do(t, h, admin, "GET", "/demo/issues/1", nil) if !strings.Contains(res.Body.String(), "Confirmed.") { t.Error("comment did not appear on the detail page") } // Reacting once adds the count, reacting again toggles it off. res = do(t, h, admin, "POST", "/demo/issues/1/react", url.Values{"emoji": {"👍"}}) if res.Code != http.StatusSeeOther { t.Fatalf("react status = %d", res.Code) } res = do(t, h, admin, "GET", "/demo/issues/1", nil) if !strings.Contains(res.Body.String(), "👍 1") { t.Error("reaction count was not shown") } do(t, h, admin, "POST", "/demo/issues/1/react", url.Values{"emoji": {"👍"}}) res = do(t, h, admin, "GET", "/demo/issues/1", nil) if strings.Contains(res.Body.String(), "👍 1") { t.Error("reaction was not toggled off") } } func TestIssuePrivateRepoHiddenFromAnonymous(t *testing.T) { h, s, admin := issueTestServer(t) repo, err := s.DB.CreateRepo(context.Background(), "secret", nil, true, "main", db.NowISO()) if err != nil { t.Fatal(err) } if repo == nil { t.Fatal("repo was not created") } if err := s.Git.Init(context.Background(), "secret", "main"); err != nil { t.Fatal(err) } if res := do(t, h, nil, "GET", "/secret/issues", nil); res.Code != http.StatusNotFound { t.Fatalf("anonymous status = %d, want 404", res.Code) } if res := do(t, h, admin, "GET", "/secret/issues", nil); res.Code != http.StatusOK { t.Fatalf("admin status = %d, want 200", res.Code) } } // The create path rejects an empty title, so the edit path must too. Without // it an issue can be renamed to nothing after the fact. func TestEditIssueRejectsEmpty(t *testing.T) { h, _, admin := issueTestServer(t) res := do(t, h, admin, "POST", "/demo/issues", url.Values{ "title": {"Real title"}, "body": {"Body"}, }) if res.Code != http.StatusFound { t.Fatalf("create issue status = %d", res.Code) } res = do(t, h, admin, "POST", "/demo/issues/1/edit", url.Values{ "title": {" "}, "edit_body": {"Body"}, }) if res.Code != http.StatusUnprocessableEntity { t.Errorf("edit with blank title status = %d, want 422", res.Code) } res = do(t, h, admin, "POST", "/demo/issues/1/comments", url.Values{"body": {"A comment"}}) if res.Code != http.StatusFound { t.Fatalf("add comment status = %d", res.Code) } res = do(t, h, admin, "POST", "/demo/issues/1/comments/1/edit", url.Values{"edit_body": {"\n\t"}}) if res.Code != http.StatusUnprocessableEntity { t.Errorf("edit with blank comment status = %d, want 422", res.Code) } }