package e2e import ( "net/http" "net/url" "os/exec" "strconv" "strings" "testing" ) // releaseCreate posts the multipart create form of the release page. func releaseCreate(s *session, repo string, fields url.Values, files ...file) *response { return s.postMultipart("/"+repo+"/releases", fields, files...) } // releaseTagged builds the form fields of a release that also creates a tag. func releaseTagged(name, tag string, extra ...string) url.Values { f := url.Values{ "name": {name}, "create_tag": {"on"}, "tag_name": {tag}, "revision": {"main"}, } for i := 0; i+1 < len(extra); i += 2 { f.Set(extra[i], extra[i+1]) } return f } func TestReleases(t *testing.T) { e := newEnv(t) e.register("alice", "password123") admin := e.admin() alice := e.login("alice", "password123") // A dedicated repo with at least one commit. e.createRepo(admin, "releases-repo") e.seedRepo("releases-repo", nil) var releaseURL, srcReleaseURL, assetReleaseURL string // ── Navigation ────────────────────────────────────────────────────────── t.Run("releases tab visible in repo nav", func(t *testing.T) { if !contains(admin.get("/releases-repo").Texts(".repo-tab"), "Releases") { t.Error("releases tab missing") } }) t.Run("releases list shows empty state when no releases", func(t *testing.T) { if !admin.get("/releases-repo/releases").Has(".empty-state") { t.Error("empty state missing") } }) // ── Access control ────────────────────────────────────────────────────── t.Run("non-admin cannot access /releases/new", func(t *testing.T) { alice.get("/releases-repo/releases/new").mustStatus(http.StatusForbidden) }) t.Run("non-admin POST to /releases returns 403", func(t *testing.T) { releaseCreate(alice, "releases-repo", url.Values{"name": {"Test"}, "tag_name": {"v0.1.0"}}). mustStatus(http.StatusForbidden) }) t.Run("unauthenticated user is redirected to login from /releases/new", func(t *testing.T) { e.anon().get("/releases-repo/releases/new").mustRedirect("/login") }) // ── Validation ────────────────────────────────────────────────────────── t.Run("missing release title shows error", func(t *testing.T) { r := releaseCreate(admin, "releases-repo", nil) if !r.Contains("Release title is required") { t.Errorf("body = %q", r.Text(".form-error")) } }) t.Run("create_tag checked but no tag name shows error", func(t *testing.T) { r := releaseCreate(admin, "releases-repo", url.Values{"name": {"Test"}, "create_tag": {"on"}}) if !r.Contains("Tag name is required") { t.Errorf("body = %q", r.Text(".form-error")) } }) // ── Create ────────────────────────────────────────────────────────────── t.Run("create a basic release", func(t *testing.T) { r := releaseCreate(admin, "releases-repo", releaseTagged("First release", "v1.0.0", "notes", "Initial stable release.\n\n- Feature A\n- Feature B")) r.mustStatus(http.StatusFound) releaseURL = r.mustRedirect("/releases-repo/releases/") if _, err := strconv.Atoi(idFromPath(t, releaseURL)); err != nil { t.Errorf("location = %q", releaseURL) } }) t.Run("duplicate tag name shows error", func(t *testing.T) { r := releaseCreate(admin, "releases-repo", releaseTagged("Duplicate", "v1.0.0")) if !r.Contains("already exists") { t.Errorf("body = %q", r.Text(".form-error")) } }) // ── List ──────────────────────────────────────────────────────────────── t.Run("release appears in list with tag badge", func(t *testing.T) { r := admin.get("/releases-repo/releases") if !strings.Contains(r.Text(".release-item-title"), "First release") { t.Errorf("title = %q", r.Text(".release-item-title")) } if !strings.Contains(r.Text(".badge"), "v1.0.0") { t.Errorf("badge = %q", r.Text(".badge")) } }) t.Run("release list shows tag badge", func(t *testing.T) { if !strings.Contains(admin.get("/releases-repo/releases").Text(".badge"), "v1.0.0") { t.Error("tag badge missing") } }) t.Run("new release button hidden for non-admin", func(t *testing.T) { if n := alice.get("/releases-repo/releases").Count(`a[href$="/releases/new"]`); n != 0 { t.Errorf("new release links = %d", n) } }) // ── Detail ────────────────────────────────────────────────────────────── t.Run("release detail shows title and tag badge", func(t *testing.T) { r := admin.get(releaseURL) if got := r.Text("h2.page-title"); got != "First release" { t.Errorf("title = %q", got) } if !strings.Contains(r.Text(".badge"), "v1.0.0") { t.Errorf("badge = %q", r.Text(".badge")) } }) t.Run("release notes rendered in detail view", func(t *testing.T) { if !strings.Contains(admin.get(releaseURL).Text(".markdown-body"), "Initial stable release") { t.Error("notes missing") } }) // ── Source archives ───────────────────────────────────────────────────── t.Run("create release with source code archives", func(t *testing.T) { r := releaseCreate(admin, "releases-repo", releaseTagged("Source release", "v1.1.0", "include_source_code", "on")) r.mustStatus(http.StatusFound) srcReleaseURL = r.mustRedirect("/releases-repo/releases/") }) t.Run("zip and tar.gz archives appear in downloads", func(t *testing.T) { names := admin.get(srcReleaseURL).Texts(".asset-name") if !contains(names, ".zip") || !contains(names, ".tar.gz") { t.Errorf("asset names = %q", names) } }) t.Run("source archive download responds with 200", func(t *testing.T) { r := admin.get(srcReleaseURL) admin.get(releaseAssetLink(t, r.Texts(".asset-name"), r, ".zip")).mustStatus(http.StatusOK) }) // ── File upload ───────────────────────────────────────────────────────── t.Run("create release with attached file", func(t *testing.T) { r := releaseCreate(admin, "releases-repo", releaseTagged("Asset release", "v1.2.0"), file{ Field: "files", Name: "release-asset.txt", Content: []byte("binary-like content for testing\n"), }) r.mustStatus(http.StatusFound) assetReleaseURL = r.mustRedirect("/releases-repo/releases/") }) t.Run("uploaded asset appears in downloads with filename and size", func(t *testing.T) { r := admin.get(assetReleaseURL) if !contains(r.Texts(".asset-name"), "release-asset.txt") { t.Errorf("asset names = %q", r.Texts(".asset-name")) } if !r.Has(".asset-size") { t.Error("asset size missing") } }) t.Run("asset download responds with 200", func(t *testing.T) { r := admin.get(assetReleaseURL) admin.get(releaseAssetLink(t, r.Texts(".asset-name"), r, "release-asset.txt")). mustStatus(http.StatusOK) }) // ── Delete ────────────────────────────────────────────────────────────── t.Run("non-admin cannot delete a release", func(t *testing.T) { alice.post(releaseURL+"/delete", nil).mustStatus(http.StatusForbidden) }) t.Run("admin can delete a release", func(t *testing.T) { admin.post(releaseURL+"/delete", nil).mustStatus(http.StatusFound) if contains(admin.get("/releases-repo/releases").Texts(".release-item-title"), "First release") { t.Error("deleted release still listed") } }) // ── Pagination ────────────────────────────────────────────────────────── t.Run("release list shows at most 20 per page", func(t *testing.T) { // Bulk-create 25 releases so the total is above one page. for i := 1; i <= 25; i++ { releaseCreate(admin, "releases-repo", releaseTagged("Page test release "+strconv.Itoa(i), "v9."+strconv.Itoa(i)+".0")) } if n := admin.get("/releases-repo/releases").Count(".issue-item"); n > 20 { t.Errorf("items = %d", n) } }) t.Run("pagination nav appears with more than 20 releases", func(t *testing.T) { if !admin.get("/releases-repo/releases").Has(".pagination") { t.Error("pagination missing") } }) t.Run("release list page 2 shows remaining releases", func(t *testing.T) { n := admin.get("/releases-repo/releases?page=2").Count(".issue-item") if n == 0 || n > 20 { t.Errorf("items = %d", n) } }) t.Run("source archive tar.zst appears in downloads", func(t *testing.T) { if _, err := exec.LookPath("zstd"); err != nil { t.Skip("zstd not available") } if !contains(admin.get(srcReleaseURL).Texts(".asset-name"), ".tar.zst") { t.Error("tar.zst archive missing") } }) } // releaseAssetLink returns the href of the asset link whose name contains sub. func releaseAssetLink(t *testing.T, names []string, r *response, sub string) string { t.Helper() for i, n := range names { if strings.Contains(n, sub) { return r.Find(".asset-name").Eq(i).AttrOr("href", "") } } t.Fatalf("no asset matching %q in %q", sub, names) return "" }