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