issues_test.go
⎇
Raw
1package web
2
3import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "net/url"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "github.com/go-chi/chi/v5"
13
14 "hearthforge/internal/config"
15 "hearthforge/internal/db"
16 "hearthforge/internal/gitcmd"
17 "hearthforge/internal/markdown"
18)
19
20// issueTestServer returns a router, the server and the admin session user.
21func issueTestServer(t *testing.T) (http.Handler, *Server, *db.SessionUser) {
22 t.Helper()
23 ctx := context.Background()
24 dir := t.TempDir()
25 d, err := db.Open(filepath.Join(dir, "hearthforge.db"))
26 if err != nil {
27 t.Fatal(err)
28 }
29 t.Cleanup(func() { d.Close() })
30 if _, err := d.InitAdmin(ctx, "hunter2"); err != nil {
31 t.Fatal(err)
32 }
33 admin, err := d.UserByName(ctx, db.AdminUsername)
34 if err != nil || admin == nil {
35 t.Fatalf("admin lookup failed: %v", err)
36 }
37 if _, err := d.CreateRepo(ctx, "demo", nil, false, "main", db.NowISO()); err != nil {
38 t.Fatal(err)
39 }
40
41 cfg := &config.Config{
42 DataDir: dir,
43 OwnerDisplayName: "Admin",
44 MaxTitleBytes: 500,
45 MaxTextBodyBytes: 100000,
46 RateLimitDisabled: true,
47 }
48 s := &Server{Cfg: cfg, DB: d, MD: markdown.New(), Git: gitcmd.New(cfg)}
49 // Repo routes 404 when the git directory is missing.
50 if err := s.Git.Init(ctx, "demo", "main"); err != nil {
51 t.Fatal(err)
52 }
53 r := chi.NewRouter()
54 s.issueRoutes(r)
55 return r, s, &db.SessionUser{ID: admin.ID, Username: db.AdminUsername, IsAdmin: true}
56}
57
58// do runs a request as the given user. Pass nil form values for a GET.
59func do(t *testing.T, h http.Handler, user *db.SessionUser, method, target string,
60 form url.Values,
61) *httptest.ResponseRecorder {
62 t.Helper()
63 var req *http.Request
64 if form == nil {
65 req = httptest.NewRequest(method, target, nil)
66 } else {
67 req = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
68 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
69 }
70 if user != nil {
71 req = req.WithContext(context.WithValue(req.Context(), userKey, user))
72 }
73 w := httptest.NewRecorder()
74 h.ServeHTTP(w, req)
75 return w
76}
77
78func TestIssueFlow(t *testing.T) {
79 h, _, admin := issueTestServer(t)
80
81 res := do(t, h, nil, "GET", "/demo/issues", nil)
82 if res.Code != http.StatusOK {
83 t.Fatalf("list status = %d", res.Code)
84 }
85 if !strings.Contains(res.Body.String(), "No open issues.") {
86 t.Error("empty list did not render the empty state")
87 }
88
89 res = do(t, h, admin, "POST", "/demo/issues", url.Values{
90 "title": {"First bug"}, "body": {"It **breaks**."},
91 })
92 if res.Code != http.StatusFound {
93 t.Fatalf("create status = %d, body %s", res.Code, res.Body.String())
94 }
95 if got := res.Header().Get("Location"); got != "/demo/issues/1" {
96 t.Fatalf("create redirected to %q", got)
97 }
98
99 res = do(t, h, admin, "GET", "/demo/issues/1", nil)
100 body := res.Body.String()
101 if res.Code != http.StatusOK {
102 t.Fatalf("detail status = %d", res.Code)
103 }
104 if !strings.Contains(body, "First bug") {
105 t.Error("detail did not show the title")
106 }
107 if !strings.Contains(body, "<strong>breaks</strong>") {
108 t.Error("detail did not render the markdown body")
109 }
110
111 res = do(t, h, admin, "POST", "/demo/issues/1/comments", url.Values{"body": {"Confirmed."}})
112 if res.Code != http.StatusFound {
113 t.Fatalf("comment status = %d", res.Code)
114 }
115 res = do(t, h, admin, "GET", "/demo/issues/1", nil)
116 if !strings.Contains(res.Body.String(), "Confirmed.") {
117 t.Error("comment did not appear on the detail page")
118 }
119
120 // Reacting once adds the count, reacting again toggles it off.
121 res = do(t, h, admin, "POST", "/demo/issues/1/react", url.Values{"emoji": {"👍"}})
122 if res.Code != http.StatusSeeOther {
123 t.Fatalf("react status = %d", res.Code)
124 }
125 res = do(t, h, admin, "GET", "/demo/issues/1", nil)
126 if !strings.Contains(res.Body.String(), "👍 1") {
127 t.Error("reaction count was not shown")
128 }
129 do(t, h, admin, "POST", "/demo/issues/1/react", url.Values{"emoji": {"👍"}})
130 res = do(t, h, admin, "GET", "/demo/issues/1", nil)
131 if strings.Contains(res.Body.String(), "👍 1") {
132 t.Error("reaction was not toggled off")
133 }
134}
135
136func TestIssuePrivateRepoHiddenFromAnonymous(t *testing.T) {
137 h, s, admin := issueTestServer(t)
138 repo, err := s.DB.CreateRepo(context.Background(), "secret", nil, true, "main", db.NowISO())
139 if err != nil {
140 t.Fatal(err)
141 }
142 if repo == nil {
143 t.Fatal("repo was not created")
144 }
145 if err := s.Git.Init(context.Background(), "secret", "main"); err != nil {
146 t.Fatal(err)
147 }
148 if res := do(t, h, nil, "GET", "/secret/issues", nil); res.Code != http.StatusNotFound {
149 t.Fatalf("anonymous status = %d, want 404", res.Code)
150 }
151 if res := do(t, h, admin, "GET", "/secret/issues", nil); res.Code != http.StatusOK {
152 t.Fatalf("admin status = %d, want 200", res.Code)
153 }
154}
155
156// The create path rejects an empty title, so the edit path must too. Without
157// it an issue can be renamed to nothing after the fact.
158func TestEditIssueRejectsEmpty(t *testing.T) {
159 h, _, admin := issueTestServer(t)
160
161 res := do(t, h, admin, "POST", "/demo/issues", url.Values{
162 "title": {"Real title"}, "body": {"Body"},
163 })
164 if res.Code != http.StatusFound {
165 t.Fatalf("create issue status = %d", res.Code)
166 }
167
168 res = do(t, h, admin, "POST", "/demo/issues/1/edit", url.Values{
169 "title": {" "}, "edit_body": {"Body"},
170 })
171 if res.Code != http.StatusUnprocessableEntity {
172 t.Errorf("edit with blank title status = %d, want 422", res.Code)
173 }
174
175 res = do(t, h, admin, "POST", "/demo/issues/1/comments", url.Values{"body": {"A comment"}})
176 if res.Code != http.StatusFound {
177 t.Fatalf("add comment status = %d", res.Code)
178 }
179 res = do(t, h, admin, "POST", "/demo/issues/1/comments/1/edit", url.Values{"edit_body": {"\n\t"}})
180 if res.Code != http.StatusUnprocessableEntity {
181 t.Errorf("edit with blank comment status = %d, want 422", res.Code)
182 }
183}
184