package root import ( "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/charmbracelet/x/ansi" "github.com/versenilvis/iris/internal/config" ) func TestFetchReleasesFiltersPrereleasesOnStableChannel(t *testing.T) { originalConfig := config.Get() t.Cleanup(func() { config.Init(originalConfig) }) cfg := config.DefaultConfig() cfg.Updater.Channel = "stable" config.Init(cfg) releases := []Release{ {TagName: "v0.6.0-nightly.abc", Prerelease: true, Body: "nightly"}, {TagName: "v0.5.2", Prerelease: false, Body: "stable"}, {TagName: "v0.5.1", Prerelease: false, Body: "stable"}, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(releases) })) defer srv.Close() t.Setenv("IRIS_CHANGELOG_URL", srv.URL) got, err := FetchReleases(0) if err != nil { t.Fatalf("FetchReleases: %v", err) } if len(got) != 2 { t.Fatalf("expected 2 stable releases, got %d: %+v", len(got), got) } for _, r := range got { if r.Prerelease { t.Errorf("prerelease %q leaked into stable channel results", r.TagName) } } } func TestFetchReleasesLimitsCount(t *testing.T) { originalConfig := config.Get() t.Cleanup(func() { config.Init(originalConfig) }) config.Init(config.DefaultConfig()) releases := []Release{ {TagName: "v0.5.2"}, {TagName: "v0.5.1"}, {TagName: "v0.5.0"}, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(releases) })) defer srv.Close() t.Setenv("IRIS_CHANGELOG_URL", srv.URL) got, err := FetchReleases(2) if err != nil { t.Fatalf("FetchReleases: %v", err) } if len(got) != 2 { t.Fatalf("expected limit of 2, got %d", len(got)) } } func seedChangelogCache(t *testing.T, fetchedAt time.Time, releases []Release) { t.Helper() path, err := changelogCachePath() if err != nil { t.Fatalf("changelogCachePath: %v", err) } data, err := json.Marshal(changelogCache{FetchedAt: fetchedAt, Channel: config.Get().Updater.Channel, Releases: releases}) if err != nil { t.Fatalf("marshal cache: %v", err) } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir cache dir: %v", err) } if err := os.WriteFile(path, data, 0o644); err != nil { t.Fatalf("write cache: %v", err) } } func TestFetchReleasesCachedServesWithinTTL(t *testing.T) { t.Setenv("XDG_CACHE_HOME", t.TempDir()) seedChangelogCache(t, time.Now(), []Release{{TagName: "v1.0.0", Body: "cached"}}) // point at an address nothing listens on: a network call here is a test failure t.Setenv("IRIS_CHANGELOG_URL", "http://127.0.0.1:1/unreachable") got, rateLimited, err := FetchReleasesCached(1, false) if err != nil { t.Fatalf("FetchReleasesCached: %v", err) } if rateLimited { t.Error("expected rateLimited=false for a fresh cache hit") } if len(got) != 1 || got[0].TagName != "v1.0.0" { t.Fatalf("expected cached release, got %+v", got) } } func TestFetchReleasesCachedChannelChangeBypassesStaleCache(t *testing.T) { originalConfig := config.Get() t.Cleanup(func() { config.Init(originalConfig) }) cfg := config.DefaultConfig() cfg.Updater.Channel = "nightly" config.Init(cfg) t.Setenv("XDG_CACHE_HOME", t.TempDir()) seedChangelogCache(t, time.Now(), []Release{{TagName: "v1.0.0-nightly.abc", Body: "nightly cache", Prerelease: true}}) cfg.Updater.Channel = "stable" config.Init(cfg) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode([]Release{{TagName: "v1.0.0", Body: "stable release"}}) })) defer srv.Close() t.Setenv("IRIS_CHANGELOG_URL", srv.URL) got, _, err := FetchReleasesCached(1, false) if err != nil { t.Fatalf("FetchReleasesCached: %v", err) } if len(got) != 1 || got[0].TagName != "v1.0.0" { t.Fatalf("expected the channel switch to bypass the nightly-channel cache and fetch fresh, got %+v", got) } } func TestFetchReleasesCachedRefreshBypassesTTL(t *testing.T) { originalConfig := config.Get() t.Cleanup(func() { config.Init(originalConfig) }) config.Init(config.DefaultConfig()) t.Setenv("XDG_CACHE_HOME", t.TempDir()) seedChangelogCache(t, time.Now(), []Release{{TagName: "v1.0.0", Body: "stale-but-fresh"}}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode([]Release{{TagName: "v2.0.0", Body: "fresh"}}) })) defer srv.Close() t.Setenv("IRIS_CHANGELOG_URL", srv.URL) got, _, err := FetchReleasesCached(1, true) if err != nil { t.Fatalf("FetchReleasesCached: %v", err) } if len(got) != 1 || got[0].TagName != "v2.0.0" { t.Fatalf("expected --refresh to bypass the cache, got %+v", got) } } func TestFetchReleasesCachedRateLimitFallsBackToStaleCache(t *testing.T) { t.Setenv("XDG_CACHE_HOME", t.TempDir()) seedChangelogCache(t, time.Now().Add(-2*changelogCacheTTL), []Release{{TagName: "v1.0.0", Body: "stale"}}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) })) defer srv.Close() t.Setenv("IRIS_CHANGELOG_URL", srv.URL) got, rateLimited, err := FetchReleasesCached(1, false) if err != nil { t.Fatalf("FetchReleasesCached: %v", err) } if !rateLimited { t.Error("expected rateLimited=true when the API 403s and a stale cache exists") } if len(got) != 1 || got[0].TagName != "v1.0.0" { t.Fatalf("expected stale cached release, got %+v", got) } } func TestFetchReleasesCachedRateLimitNoCacheReturnsError(t *testing.T) { t.Setenv("XDG_CACHE_HOME", t.TempDir()) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) })) defer srv.Close() t.Setenv("IRIS_CHANGELOG_URL", srv.URL) _, _, err := FetchReleasesCached(1, false) if !errors.Is(err, ErrRateLimited) { t.Fatalf("expected ErrRateLimited, got %v", err) } } func TestPrintReleaseSuppressesUpdateCTAWhenCurrent(t *testing.T) { originalVersion := Version Version = "v1.0.0" t.Cleanup(func() { Version = originalVersion }) var buf bytes.Buffer printRelease(&buf, Release{TagName: "v1.0.0", Body: "## Changelog\n### Bug fixes\n* abc1234 fix something\n"}, true) if strings.Contains(buf.String(), "run `iris update`") { t.Error("expected no update CTA when already on the latest version") } if !strings.Contains(buf.String(), "(current)") { t.Error("expected the version label to be marked (current)") } } func TestPrintReleaseShowsUpdateCTAWhenNewer(t *testing.T) { originalVersion := Version Version = "v0.9.0" t.Cleanup(func() { Version = originalVersion }) var buf bytes.Buffer printRelease(&buf, Release{TagName: "v1.0.0", Body: "## Changelog\n### Bug fixes\n* abc1234 fix something\n"}, true) if !strings.Contains(buf.String(), "run `iris update`") { t.Error("expected an update CTA when a newer release is shown") } } func TestPrintChangelogBodyStopsAtUpdateFooter(t *testing.T) { var buf bytes.Buffer body := "## Changelog\n### Bug fixes\n* abc1234 fix something\n## Update\n```bash\niris update\n```\n" printChangelogBody(&buf, body) plain := ansi.Strip(buf.String()) if strings.Contains(plain, "Update") { t.Error("expected the GoReleaser footer to be excluded from rendered body") } if !strings.Contains(plain, "fix something") { t.Errorf("expected the changelog entry to be rendered, got %q", plain) } } func TestPrintChangelogBodyRendersArbitraryMarkdown(t *testing.T) { var buf bytes.Buffer body := "# 🌙 **Nightly build** - `v0.6.0-nightly.abc123`\nNightly release from commit: abc123\n" printChangelogBody(&buf, body) plain := ansi.Strip(buf.String()) if !strings.Contains(plain, "Nightly build") { t.Errorf("expected the body rendered via glamour, got %q", plain) } } func TestPrintChangelogBodyEmptyBodyPrintsNothing(t *testing.T) { var buf bytes.Buffer printChangelogBody(&buf, "") if buf.Len() != 0 { t.Errorf("expected no output for an empty body, got %q", buf.String()) } } func TestPrintChangelogBodyStripsRedundantHeadingAndBlankRuns(t *testing.T) { var buf bytes.Buffer body := "## Changelog\n### Bug fixes\n* abc1234 fix something\n" printChangelogBody(&buf, body) plain := ansi.Strip(buf.String()) if strings.Contains(plain, "Changelog") { t.Errorf("expected the redundant ## Changelog heading to be stripped, got %q", plain) } if strings.Contains(buf.String(), "\n\n\n") { t.Errorf("expected consecutive blank lines to be squeezed, got %q", buf.String()) } } func TestStripRedundantHeadingRemovesExactGoReleaserLabelOnly(t *testing.T) { got := stripRedundantHeading("## Changelog\n### Bug fixes\n* item\n") want := "### Bug fixes\n* item\n" if got != want { t.Errorf("got %q, want %q", got, want) } } func TestStripRedundantHeadingPreservesHandWrittenHeadings(t *testing.T) { body := "## Theme is here\n\n" got := stripRedundantHeading(body) if got != body { t.Errorf("expected a hand-written ## heading to be preserved, got %q", got) } } func TestPrintChangelogBodyPreservesHandWrittenAnnouncement(t *testing.T) { var buf bytes.Buffer body := "## Theme is here\r\n\r\n\r\n## Update\r\n```bash\r\niris update\r\n```\r\n" printChangelogBody(&buf, body) plain := ansi.Strip(buf.String()) if !strings.Contains(plain, "Theme is here") { t.Errorf("expected the hand-written release heading to be rendered, got %q", plain) } } func TestSqueezeBlankLinesCollapsesRuns(t *testing.T) { got := squeezeBlankLines("a\n\n\n\nb\n") want := "a\n\nb\n" if got != want { t.Errorf("got %q, want %q", got, want) } } func TestChangelogCmdShowsSpecificVersion(t *testing.T) { originalConfig := config.Get() t.Cleanup(func() { config.Init(originalConfig) }) config.Init(config.DefaultConfig()) t.Setenv("XDG_CACHE_HOME", t.TempDir()) releases := []Release{ {TagName: "v0.5.2", Body: "## Changelog\n### Bug fixes\n* xyz9999 unrelated fix\n"}, {TagName: "v0.5.1", Body: "## Changelog\n### Bug fixes\n* abc1234 fix mode\n"}, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(releases) })) defer srv.Close() t.Setenv("IRIS_CHANGELOG_URL", srv.URL) var out, errOut bytes.Buffer ChangelogCmd.SetOut(&out) ChangelogCmd.SetErr(&errOut) t.Cleanup(func() { ChangelogCmd.SetOut(nil) ChangelogCmd.SetErr(nil) }) ChangelogCmd.Run(ChangelogCmd, []string{"v0.5.1"}) got := ansi.Strip(out.String()) if !strings.Contains(got, "v0.5.1") { t.Fatalf("expected the requested version in output, got %q", got) } if strings.Contains(got, "v0.5.2") || strings.Contains(got, "unrelated fix") { t.Errorf("expected only the requested version, got %q", got) } if !strings.Contains(got, "fix mode") { t.Errorf("expected the matched release's body, got %q", got) } } func TestChangelogCmdVersionNotFound(t *testing.T) { originalConfig := config.Get() t.Cleanup(func() { config.Init(originalConfig) }) config.Init(config.DefaultConfig()) t.Setenv("XDG_CACHE_HOME", t.TempDir()) releases := []Release{{TagName: "v0.5.2", Body: "latest"}} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(releases) })) defer srv.Close() t.Setenv("IRIS_CHANGELOG_URL", srv.URL) var out, errOut bytes.Buffer ChangelogCmd.SetOut(&out) ChangelogCmd.SetErr(&errOut) t.Cleanup(func() { ChangelogCmd.SetOut(nil) ChangelogCmd.SetErr(nil) }) ChangelogCmd.Run(ChangelogCmd, []string{"v9.9.9"}) if !strings.Contains(errOut.String(), "not found") { t.Errorf("expected a not-found error, got stdout=%q stderr=%q", out.String(), errOut.String()) } }