releases_test.go
⎇
Raw
1package e2e
2
3import (
4 "net/http"
5 "net/url"
6 "os/exec"
7 "strconv"
8 "strings"
9 "testing"
10)
11
12// releaseCreate posts the multipart create form of the release page.
13func releaseCreate(s *session, repo string, fields url.Values, files ...file) *response {
14 return s.postMultipart("/"+repo+"/releases", fields, files...)
15}
16
17// releaseTagged builds the form fields of a release that also creates a tag.
18func releaseTagged(name, tag string, extra ...string) url.Values {
19 f := url.Values{
20 "name": {name}, "create_tag": {"on"}, "tag_name": {tag}, "revision": {"main"},
21 }
22 for i := 0; i+1 < len(extra); i += 2 {
23 f.Set(extra[i], extra[i+1])
24 }
25 return f
26}
27
28func TestReleases(t *testing.T) {
29 e := newEnv(t)
30 e.register("alice", "password123")
31 admin := e.admin()
32 alice := e.login("alice", "password123")
33
34 // A dedicated repo with at least one commit.
35 e.createRepo(admin, "releases-repo")
36 e.seedRepo("releases-repo", nil)
37
38 var releaseURL, srcReleaseURL, assetReleaseURL string
39
40 // ── Navigation ──────────────────────────────────────────────────────────
41
42 t.Run("releases tab visible in repo nav", func(t *testing.T) {
43 if !contains(admin.get("/releases-repo").Texts(".repo-tab"), "Releases") {
44 t.Error("releases tab missing")
45 }
46 })
47
48 t.Run("releases list shows empty state when no releases", func(t *testing.T) {
49 if !admin.get("/releases-repo/releases").Has(".empty-state") {
50 t.Error("empty state missing")
51 }
52 })
53
54 // ── Access control ──────────────────────────────────────────────────────
55
56 t.Run("non-admin cannot access /releases/new", func(t *testing.T) {
57 alice.get("/releases-repo/releases/new").mustStatus(http.StatusForbidden)
58 })
59
60 t.Run("non-admin POST to /releases returns 403", func(t *testing.T) {
61 releaseCreate(alice, "releases-repo", url.Values{"name": {"Test"}, "tag_name": {"v0.1.0"}}).
62 mustStatus(http.StatusForbidden)
63 })
64
65 t.Run("unauthenticated user is redirected to login from /releases/new", func(t *testing.T) {
66 e.anon().get("/releases-repo/releases/new").mustRedirect("/login")
67 })
68
69 // ── Validation ──────────────────────────────────────────────────────────
70
71 t.Run("missing release title shows error", func(t *testing.T) {
72 r := releaseCreate(admin, "releases-repo", nil)
73 if !r.Contains("Release title is required") {
74 t.Errorf("body = %q", r.Text(".form-error"))
75 }
76 })
77
78 t.Run("create_tag checked but no tag name shows error", func(t *testing.T) {
79 r := releaseCreate(admin, "releases-repo", url.Values{"name": {"Test"}, "create_tag": {"on"}})
80 if !r.Contains("Tag name is required") {
81 t.Errorf("body = %q", r.Text(".form-error"))
82 }
83 })
84
85 // ── Create ──────────────────────────────────────────────────────────────
86
87 t.Run("create a basic release", func(t *testing.T) {
88 r := releaseCreate(admin, "releases-repo", releaseTagged("First release", "v1.0.0",
89 "notes", "Initial stable release.\n\n- Feature A\n- Feature B"))
90 r.mustStatus(http.StatusFound)
91 releaseURL = r.mustRedirect("/releases-repo/releases/")
92 if _, err := strconv.Atoi(idFromPath(t, releaseURL)); err != nil {
93 t.Errorf("location = %q", releaseURL)
94 }
95 })
96
97 t.Run("duplicate tag name shows error", func(t *testing.T) {
98 r := releaseCreate(admin, "releases-repo", releaseTagged("Duplicate", "v1.0.0"))
99 if !r.Contains("already exists") {
100 t.Errorf("body = %q", r.Text(".form-error"))
101 }
102 })
103
104 // ── List ────────────────────────────────────────────────────────────────
105
106 t.Run("release appears in list with tag badge", func(t *testing.T) {
107 r := admin.get("/releases-repo/releases")
108 if !strings.Contains(r.Text(".release-item-title"), "First release") {
109 t.Errorf("title = %q", r.Text(".release-item-title"))
110 }
111 if !strings.Contains(r.Text(".badge"), "v1.0.0") {
112 t.Errorf("badge = %q", r.Text(".badge"))
113 }
114 })
115
116 t.Run("release list shows tag badge", func(t *testing.T) {
117 if !strings.Contains(admin.get("/releases-repo/releases").Text(".badge"), "v1.0.0") {
118 t.Error("tag badge missing")
119 }
120 })
121
122 t.Run("new release button hidden for non-admin", func(t *testing.T) {
123 if n := alice.get("/releases-repo/releases").Count(`a[href$="/releases/new"]`); n != 0 {
124 t.Errorf("new release links = %d", n)
125 }
126 })
127
128 // ── Detail ──────────────────────────────────────────────────────────────
129
130 t.Run("release detail shows title and tag badge", func(t *testing.T) {
131 r := admin.get(releaseURL)
132 if got := r.Text("h2.page-title"); got != "First release" {
133 t.Errorf("title = %q", got)
134 }
135 if !strings.Contains(r.Text(".badge"), "v1.0.0") {
136 t.Errorf("badge = %q", r.Text(".badge"))
137 }
138 })
139
140 t.Run("release notes rendered in detail view", func(t *testing.T) {
141 if !strings.Contains(admin.get(releaseURL).Text(".markdown-body"), "Initial stable release") {
142 t.Error("notes missing")
143 }
144 })
145
146 // ── Source archives ─────────────────────────────────────────────────────
147
148 t.Run("create release with source code archives", func(t *testing.T) {
149 r := releaseCreate(admin, "releases-repo",
150 releaseTagged("Source release", "v1.1.0", "include_source_code", "on"))
151 r.mustStatus(http.StatusFound)
152 srcReleaseURL = r.mustRedirect("/releases-repo/releases/")
153 })
154
155 t.Run("zip and tar.gz archives appear in downloads", func(t *testing.T) {
156 names := admin.get(srcReleaseURL).Texts(".asset-name")
157 if !contains(names, ".zip") || !contains(names, ".tar.gz") {
158 t.Errorf("asset names = %q", names)
159 }
160 })
161
162 t.Run("source archive download responds with 200", func(t *testing.T) {
163 r := admin.get(srcReleaseURL)
164 admin.get(releaseAssetLink(t, r.Texts(".asset-name"), r, ".zip")).mustStatus(http.StatusOK)
165 })
166
167 // ── File upload ─────────────────────────────────────────────────────────
168
169 t.Run("create release with attached file", func(t *testing.T) {
170 r := releaseCreate(admin, "releases-repo", releaseTagged("Asset release", "v1.2.0"),
171 file{
172 Field: "files", Name: "release-asset.txt",
173 Content: []byte("binary-like content for testing\n"),
174 })
175 r.mustStatus(http.StatusFound)
176 assetReleaseURL = r.mustRedirect("/releases-repo/releases/")
177 })
178
179 t.Run("uploaded asset appears in downloads with filename and size", func(t *testing.T) {
180 r := admin.get(assetReleaseURL)
181 if !contains(r.Texts(".asset-name"), "release-asset.txt") {
182 t.Errorf("asset names = %q", r.Texts(".asset-name"))
183 }
184 if !r.Has(".asset-size") {
185 t.Error("asset size missing")
186 }
187 })
188
189 t.Run("asset download responds with 200", func(t *testing.T) {
190 r := admin.get(assetReleaseURL)
191 admin.get(releaseAssetLink(t, r.Texts(".asset-name"), r, "release-asset.txt")).
192 mustStatus(http.StatusOK)
193 })
194
195 // ── Delete ──────────────────────────────────────────────────────────────
196
197 t.Run("non-admin cannot delete a release", func(t *testing.T) {
198 alice.post(releaseURL+"/delete", nil).mustStatus(http.StatusForbidden)
199 })
200
201 t.Run("admin can delete a release", func(t *testing.T) {
202 admin.post(releaseURL+"/delete", nil).mustStatus(http.StatusFound)
203 if contains(admin.get("/releases-repo/releases").Texts(".release-item-title"), "First release") {
204 t.Error("deleted release still listed")
205 }
206 })
207
208 // ── Pagination ──────────────────────────────────────────────────────────
209
210 t.Run("release list shows at most 20 per page", func(t *testing.T) {
211 // Bulk-create 25 releases so the total is above one page.
212 for i := 1; i <= 25; i++ {
213 releaseCreate(admin, "releases-repo",
214 releaseTagged("Page test release "+strconv.Itoa(i), "v9."+strconv.Itoa(i)+".0"))
215 }
216 if n := admin.get("/releases-repo/releases").Count(".issue-item"); n > 20 {
217 t.Errorf("items = %d", n)
218 }
219 })
220
221 t.Run("pagination nav appears with more than 20 releases", func(t *testing.T) {
222 if !admin.get("/releases-repo/releases").Has(".pagination") {
223 t.Error("pagination missing")
224 }
225 })
226
227 t.Run("release list page 2 shows remaining releases", func(t *testing.T) {
228 n := admin.get("/releases-repo/releases?page=2").Count(".issue-item")
229 if n == 0 || n > 20 {
230 t.Errorf("items = %d", n)
231 }
232 })
233
234 t.Run("source archive tar.zst appears in downloads", func(t *testing.T) {
235 if _, err := exec.LookPath("zstd"); err != nil {
236 t.Skip("zstd not available")
237 }
238 if !contains(admin.get(srcReleaseURL).Texts(".asset-name"), ".tar.zst") {
239 t.Error("tar.zst archive missing")
240 }
241 })
242}
243
244// releaseAssetLink returns the href of the asset link whose name contains sub.
245func releaseAssetLink(t *testing.T, names []string, r *response, sub string) string {
246 t.Helper()
247 for i, n := range names {
248 if strings.Contains(n, sub) {
249 return r.Find(".asset-name").Eq(i).AttrOr("href", "")
250 }
251 }
252 t.Fatalf("no asset matching %q in %q", sub, names)
253 return ""
254}
255