package test import ( "RenewCMS/internal/domain/update" "RenewCMS/internal/infrastructure/useCases" "errors" "reflect" "testing" "time" ) type fakeReleaseFeed struct { release update.Release err error calls int } var _ update.ReleaseFeed = &fakeReleaseFeed{} func (f *fakeReleaseFeed) Latest() (update.Release, error) { f.calls++ if f.err != nil { return update.Release{}, f.err } return f.release, nil } type fakeUpdateStore struct { result update.CheckResult found bool saveErr error saves int } var _ update.StatusStore = &fakeUpdateStore{} func (f *fakeUpdateStore) Load() (update.CheckResult, bool) { return f.result, f.found } func (f *fakeUpdateStore) Save(result update.CheckResult) error { f.saves++ if f.saveErr != nil { return f.saveErr } f.result = result f.found = true return nil } var updateTestCheckedAt = time.Date(2026, 9, 30, 9, 41, 0, 0, time.UTC) func newStoredUpdateResult() update.CheckResult { return update.CheckResult{ Latest: "v2.3.0-beta", ReleaseURL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta", Notes: update.ReleaseNotes{ Version: "v2.3.0-beta", ReleasedAt: time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC), Items: []string{"Media library"}, }, CheckedAt: updateTestCheckedAt, } } func TestVersionComparison(t *testing.T) { cases := []struct { current, latest string available bool }{ {"v2.2.0-beta", "v2.3.0-beta", true}, {"v2.5.0-beta", "v2.5.0", true}, {"v2.5.0", "v2.5.0-beta", false}, {"v2.9.0", "v2.10.0", true}, {"v2.5.0-beta", "v2.5.0-beta", false}, {"v2.5.0", "v2.4.9", false}, {"dev", "v9.9.9", false}, {"v2.5.0-beta.2", "v2.5.0-beta.10", true}, {"v2.5.0-alpha", "v2.5.0-beta", true}, {"2.0.0-beta", "v2.1.0-beta", true}, {"v2.5", "v2.5.0", false}, {"v2.2.0", "garbage", false}, {"v1.0.0-1", "v1.0.0-alpha", true}, {"v1.0.0-alpha", "v1.0.0-1", false}, {"v1.0.0-beta", "v1.0.0-beta.1", true}, {"v1.0.0-beta.1", "v1.0.0-beta", false}, {"v1.0.0-beta.2", "v1.0.0-beta.1", false}, {"v1.0.0-beta", "v1.0.0-alpha", false}, {"v1.0.0", "v1.0.0", false}, {"v1.1.0", "v1.0.9", false}, } for _, c := range cases { t.Run(c.current+" to "+c.latest, func(t *testing.T) { if got := update.IsNewer(c.current, c.latest); got != c.available { t.Errorf("IsNewer(%q, %q) = %v, want %v", c.current, c.latest, got, c.available) } }) } } func TestParseVersionRejectsGarbage(t *testing.T) { for _, raw := range []string{"", "dev", "v", "v1.x.0", "v1.2.3-", "v-1.0.0"} { if _, err := update.ParseVersion(raw); !errors.Is(err, update.ErrInvalidVersion) { t.Errorf("ParseVersion(%q) error = %v, want ErrInvalidVersion", raw, err) } } } func TestNewestReleaseIsChosenRegardlessOfOrder(t *testing.T) { releases := []update.Release{{Tag: "v2.4.0-beta"}, {Tag: "v2.5.0-beta"}, {Tag: "v2.3.0-beta"}} newest, err := update.Newest(releases) if err != nil || newest.Tag != "v2.5.0-beta" { t.Fatalf("Newest = %q, %v, want v2.5.0-beta", newest.Tag, err) } } func TestNewestReleaseOfNothingIsAnError(t *testing.T) { if _, err := update.Newest(nil); !errors.Is(err, update.ErrNoRelease) { t.Errorf("error = %v, want ErrNoRelease", err) } if _, err := update.Newest([]update.Release{{Tag: "nightly"}}); !errors.Is(err, update.ErrNoRelease) { t.Errorf("error = %v, want ErrNoRelease for an unparsable tag", err) } } func TestReleaseNotesBecomeAPlainTextList(t *testing.T) { published := time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC) body := "## What's new\n- **Media** library\n- Fixed [caching](http://x) bug\n\n* Faster start" notes := update.BuildNotes("v2.3.0-beta", published, body) want := []string{"Media library", "Fixed caching bug", "Faster start"} if !reflect.DeepEqual(notes.Items, want) { t.Errorf("items = %v, want %v", notes.Items, want) } if !notes.ReleasedAt.Equal(published) || notes.Version != "v2.3.0-beta" { t.Errorf("notes = %+v", notes) } } func TestReleaseNotesWithoutBulletsHaveNoItems(t *testing.T) { notes := update.BuildNotes("v1.0.0", time.Time{}, "Just an intro.\n\n**Group**\n") if notes.Items == nil || len(notes.Items) != 0 { t.Errorf("items = %#v, want an empty non-nil list", notes.Items) } } func TestReleaseNotesStripInlineMarkdown(t *testing.T) { notes := update.BuildNotes("v1.0.0", time.Time{}, "- Run `task build` and *enjoy* __it__") if want := []string{"Run task build and enjoy it"}; !reflect.DeepEqual(notes.Items, want) { t.Errorf("items = %v, want %v", notes.Items, want) } } func TestGetUpdateStatusUseCase(t *testing.T) { t.Run("never checked", func(t *testing.T) { status := newUpdateStatusUseCase(&fakeUpdateStore{}, &fakeJobStore{}, &fakeHelperDetector{}, "v2.2.0-beta").GetStatus(true) if status.Current != "v2.2.0-beta" || status.Latest != "" || status.Available { t.Errorf("status = %+v", status) } if status.ReleaseURL != nil || status.Notes != nil || status.CheckedAt != nil || status.Progress != nil || status.Error != nil || status.StartedAt != nil { t.Errorf("never-checked values must be nil: %+v", status) } if status.State != update.StateIdle || status.CanApply { t.Errorf("state = %q, can_apply = %v", status.State, status.CanApply) } }) t.Run("stored result", func(t *testing.T) { store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true} status := newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, "v2.2.0-beta").GetStatus(true) if status.Latest != "v2.3.0-beta" || !status.Available || status.CanApply || status.State != update.StateIdle { t.Errorf("status = %+v", status) } if status.ReleaseURL == nil || *status.ReleaseURL != store.result.ReleaseURL { t.Errorf("release_url = %v", status.ReleaseURL) } if status.CheckedAt == nil || !status.CheckedAt.Equal(updateTestCheckedAt) { t.Errorf("checked_at = %v", status.CheckedAt) } }) t.Run("dev build never reports an update", func(t *testing.T) { store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true} if newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, "dev").GetStatus(true).Available { t.Error("a dev build must not report an update") } }) } func TestCheckForUpdateUseCase(t *testing.T) { newRelease := update.Release{Tag: "v2.3.0-beta", URL: "https://git.example/r/v2.3.0-beta", PublishedAt: updateTestCheckedAt} now := func() time.Time { return updateTestCheckedAt } t.Run("stores the result and reports it", func(t *testing.T) { store := &fakeUpdateStore{} feed := &fakeReleaseFeed{release: newRelease} status, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now, discardRecorder).Check(testCtx) if err != nil || !status.Available || status.Latest != "v2.3.0-beta" { t.Fatalf("status = %+v, err = %v", status, err) } if store.result.Latest != "v2.3.0-beta" || store.result.ReleaseURL != newRelease.URL || !store.result.CheckedAt.Equal(updateTestCheckedAt) { t.Errorf("stored = %+v", store.result) } }) t.Run("nothing newer", func(t *testing.T) { feed := &fakeReleaseFeed{release: update.Release{Tag: "v2.2.0-beta"}} status, err := useCases.NewCheckForUpdateUseCase(feed, &fakeUpdateStore{}, "v2.2.0-beta", now, discardRecorder).Check(testCtx) if err != nil || status.Available { t.Errorf("status = %+v, err = %v", status, err) } }) t.Run("feed failure keeps the previous result", func(t *testing.T) { store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true} feed := &fakeReleaseFeed{err: update.ErrFeedUnavailable} _, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now, discardRecorder).Check(testCtx) if !errors.Is(err, update.ErrFeedUnavailable) { t.Errorf("err = %v", err) } if store.saves != 0 || store.result.Latest != "v2.3.0-beta" { t.Errorf("store changed: %+v saves %d", store.result, store.saves) } }) t.Run("store failure is returned", func(t *testing.T) { store := &fakeUpdateStore{saveErr: errRepositoryFailure} feed := &fakeReleaseFeed{release: newRelease} if _, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now, discardRecorder).Check(testCtx); !errors.Is(err, errRepositoryFailure) { t.Errorf("err = %v", err) } }) }