releases_test.go
| 1 | package e2e |
| 2 | |
| 3 | import ( |
| 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. |
| 13 | func 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. |
| 18 | func 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 | |
| 28 | func 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. |
| 245 | func 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 |