package web import ( "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/shcizo/package-updater/internal/master/model" ) const sluskRepo = "gitea.shcizo.se/shcizo/slusk" func TestServicePage_VersionPicker(t *testing.T) { srv, store, _, _, _ := newTestEnv(t) store.services[0].ImageRepo = sluskRepo store.services[0].ActualDigest = "sha256:cur" first := time.Date(2026, 10, 12, 9, 5, 0, 0, time.UTC) store.versions = []model.ImageVersion{ {ID: 31, Repo: sluskRepo, Tag: "1.5.0", Digest: "sha256:" + strings.Repeat("a", 40), Source: "poll", FirstSeenAt: first}, {ID: 32, Repo: sluskRepo, Tag: "1.4.2", Digest: "sha256:cur", Source: "running", FirstSeenAt: first}, {ID: 33, Repo: sluskRepo, Digest: "sha256:" + strings.Repeat("b", 40), Source: "running", FirstSeenAt: first}, {ID: 34, Repo: sluskRepo, Tag: "main", Source: "webhook", FirstSeenAt: first}, {ID: 40, Repo: "other/repo", Tag: "x", Source: "poll", FirstSeenAt: first}, } _, body := get(t, srv, "/services/1") for _, id := range []string{"31", "32", "33", "34"} { assert.Contains(t, body, ``, id) } assert.NotContains(t, body, `name="version" value="40"`, "versions of other repos are not listed") assert.Equal(t, 4, strings.Count(body, `name="version"`)) assert.Contains(t, body, `name="csrf_token" value="csrf"`) assert.Contains(t, body, `title="`+sluskRepo+`:1.5.0@sha256:`+strings.Repeat("a", 40)+`"`, "full ref as title") assert.Contains(t, body, sluskRepo+":1.5.0@sha256:"+strings.Repeat("a", 12), "short ref as text") assert.Equal(t, 1, strings.Count(body, `running`), "only the running digest is marked") assert.Equal(t, 1, strings.Count(body, "the service will follow no tag afterwards"), "only the tagless row") assert.Contains(t, body, "2026-10-12 09:05") assert.NotContains(t, body, "auto mode", "no auto warning for a manual service") } func TestServicePage_VersionPickerShowsAtMostTenRows(t *testing.T) { srv, store, _, _, _ := newTestEnv(t) store.services[0].ImageRepo = sluskRepo for i := int64(1); i <= 12; i++ { store.versions = append(store.versions, model.ImageVersion{ID: i, Repo: sluskRepo, Tag: "t", Digest: "sha256:d", Source: "poll"}) } _, body := get(t, srv, "/services/1") assert.Equal(t, 10, strings.Count(body, `name="version"`)) } func TestServicePage_VersionPickerWarnsForAutoServices(t *testing.T) { srv, store, _, _, _ := newTestEnv(t) store.services[0].UpdateMode = "auto" _, body := get(t, srv, "/services/1") assert.Contains(t, body, "This service is in auto mode") } func TestServicePage_VersionPickerHiddenWhenServiceCannotBeUpdated(t *testing.T) { for _, reason := range []string{model.ReasonContainer, model.ReasonRemoved, model.ReasonBuilt} { t.Run(reason, func(t *testing.T) { srv, store, _, _, jobs := newTestEnv(t) store.services[0].ImageRepo = sluskRepo store.versions = []model.ImageVersion{{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Source: "poll"}} jobs.autoErr = &model.NotUpdatableError{Reason: reason} _, body := get(t, srv, "/services/1") assert.Contains(t, body, "

Versions

") assert.Contains(t, body, "1.5.0", "the history is still shown") assert.NotContains(t, body, `name="version"`) }) } } func TestServicePage_VersionPickerOffersVersionsToAServiceThatFollowsNoTag(t *testing.T) { srv, store, _, _, jobs := newTestEnv(t) store.services[0].ImageRepo = sluskRepo store.versions = []model.ImageVersion{{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Digest: "sha256:a", Source: "poll"}} jobs.updatable = &model.NotUpdatableError{Reason: model.ReasonNoTag} // autoErr stays nil: an explicit ref is fine _, body := get(t, srv, "/services/1") assert.Contains(t, body, `name="version" value="31"`) assert.NotContains(t, body, ``, "the plain Update button stays hidden") } func TestServicePage_PollingRow(t *testing.T) { tests := []struct { name string edit func(*model.Service) want string failed bool }{ {"polled", func(s *model.Service) { s.ActualDigest = "sha256:cur" }, "every 6h", false}, {"poll error", func(s *model.Service) { s.ActualDigest = "sha256:cur"; s.LastPollError = "head x: 401 " }, "last check failed: head x: 401 <b>", true}, {"built", func(s *model.Service) { s.ActualDigest = "sha256:cur"; s.Built = true }, "not polled: built from source", false}, {"no digest", func(s *model.Service) {}, "not polled: no registry digest", false}, {"no tag", func(s *model.Service) { s.ActualRef = "r@sha256:a"; s.ActualDigest = "sha256:a" }, "not polled: follows no tag", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv, store, _, _, _ := newTestEnvPolling(t, 6*time.Hour) tt.edit(&store.services[0]) _, body := get(t, srv, "/services/1") assert.Contains(t, body, "Polling`+tt.want)) }) } } func TestShortDuration(t *testing.T) { tests := []struct { d time.Duration want string }{ {6 * time.Hour, "6h"}, {90 * time.Minute, "1h30m"}, {30 * time.Minute, "30m"}, {90 * time.Second, "1m30s"}, {45 * time.Second, "45s"}, {6*time.Hour + 5*time.Second, "6h0m5s"}, } for _, tt := range tests { t.Run(tt.want, func(t *testing.T) { assert.Equal(t, tt.want, shortDuration(tt.d)) }) } } func postVersion(t *testing.T, srv *httptest.Server, version string) (*http.Response, string) { t.Helper() resp, err := noRedirectClient().PostForm(srv.URL+"/services/1/update", url.Values{"csrf_token": {"csrf"}, "version": {version}}) require.NoError(t, err) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return resp, string(body) } func TestUpdateService_VersionStartsThatVersion(t *testing.T) { srv, _, _, _, jobs := newTestEnv(t) jobs.job = model.Job{ID: 42} resp, _ := postVersion(t, srv, "31") assert.Equal(t, http.StatusSeeOther, resp.StatusCode) assert.Equal(t, "/jobs/42", resp.Header.Get("Location")) assert.Equal(t, []int64{31}, jobs.triggered) assert.Equal(t, []int64{1}, jobs.triggerSvc) assert.Equal(t, "alice", jobs.triggeredBy()) assert.Zero(t, jobs.plainCalls) } func TestUpdateService_WithoutVersionTriggersTheFollowedTag(t *testing.T) { srv, _, _, _, jobs := newTestEnv(t) jobs.job = model.Job{ID: 42} resp, _ := postVersion(t, srv, "") assert.Equal(t, http.StatusSeeOther, resp.StatusCode) assert.Equal(t, 1, jobs.plainCalls) assert.Empty(t, jobs.triggered) } func TestUpdateService_VersionErrors(t *testing.T) { tests := []struct { name string version string err error wantCode int wantBody string wantJob string // Location header }{ {"not a number", "abc", nil, http.StatusBadRequest, "invalid version", ""}, {"a ref instead of an id", "repo:evil@sha256:x", nil, http.StatusBadRequest, "invalid version", ""}, {"unknown version or other repo", "77", model.ErrNotFound, http.StatusNotFound, "not found", ""}, {"not updatable", "31", &model.NotUpdatableError{Reason: model.ReasonBuilt}, http.StatusConflict, "Cannot update: " + model.ReasonBuilt + ".", ""}, {"job already active", "31", model.ErrJobActive, http.StatusSeeOther, "", "/jobs/41"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv, _, _, _, jobs := newTestEnv(t) jobs.err, jobs.job = tt.err, model.Job{ID: 41} resp, body := postVersion(t, srv, tt.version) assert.Equal(t, tt.wantCode, resp.StatusCode) assert.Contains(t, body, tt.wantBody) assert.Equal(t, tt.wantJob, resp.Header.Get("Location")) if tt.wantCode == http.StatusBadRequest { assert.Empty(t, jobs.triggered, "no job for a malformed version") } }) } } func TestOverview_ShowsFollowsNoTagAndPollFailedBadges(t *testing.T) { srv, store, _, _, _ := newTestEnv(t) store.services[0].LastPollError = `head x: "boom"