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