feat(web): update-mode toggle and available-version badge (#8)

This commit is contained in:
2026-10-12 20:41:41 +02:00
parent 5d95a35b3b
commit 71d66846f2
6 changed files with 239 additions and 8 deletions
+137
View File
@@ -46,6 +46,29 @@ func (f *fakeStore) GetService(_ context.Context, id int64) (model.Service, erro
return model.Service{}, model.ErrNotFound
}
func (f *fakeStore) SetUpdateMode(_ context.Context, id int64, mode string) error {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.services {
if f.services[i].ID == id && f.services[i].RemovedAt == nil {
f.services[i].UpdateMode = mode
return nil
}
}
return model.ErrNotFound
}
func (f *fakeStore) modeOf(id int64) string {
f.mu.Lock()
defer f.mu.Unlock()
for _, s := range f.services {
if s.ID == id {
return s.UpdateMode
}
}
return ""
}
func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) {
for _, j := range f.jobs {
if j.ID == id {
@@ -99,6 +122,7 @@ func (f *fakeStore) revoked() int64 {
// fakeJobs returns scripted results and records who asked for the update.
type fakeJobs struct {
updatable error
autoErr error
job model.Job
err error
@@ -108,6 +132,8 @@ type fakeJobs struct {
func (f *fakeJobs) Updatable(model.Service) error { return f.updatable }
func (f *fakeJobs) AutoAllowed(model.Service) error { return f.autoErr }
func (f *fakeJobs) Trigger(_ context.Context, _ int64, by string) (model.Job, error) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -504,3 +530,114 @@ func TestJob_NotFound(t *testing.T) {
assert.Equal(t, http.StatusNotFound, code)
}
}
func postMode(t *testing.T, srv *httptest.Server, id string, form url.Values) (*http.Response, string) {
t.Helper()
form.Set("csrf_token", "csrf")
resp, err := noRedirectClient().PostForm(srv.URL+"/services/"+id+"/mode", form)
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp, string(body)
}
func TestSetMode_AutoRedirectsToServiceAndPublishes(t *testing.T) {
srv, store, _, broker, _ := newTestEnv(t)
sub, cancel := broker.Subscribe()
defer cancel()
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"auto"}})
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, "/services/1", resp.Header.Get("Location"))
assert.Equal(t, "auto", store.modeOf(1))
assert.Equal(t, events.Services, <-sub)
}
func TestSetMode_ReturnToOverview(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"auto"}, "return": {"overview"}})
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, "/", resp.Header.Get("Location"))
assert.Equal(t, "auto", store.modeOf(1))
}
func TestSetMode_AutoRefusedShows409(t *testing.T) {
srv, store, _, _, jobs := newTestEnv(t)
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
resp, body := postMode(t, srv, "1", url.Values{"mode": {"auto"}})
assert.Equal(t, http.StatusConflict, resp.StatusCode)
assert.Contains(t, body, "Cannot enable auto: "+model.ReasonBuilt+".")
assert.Equal(t, "manual", store.modeOf(1))
}
func TestSetMode_ManualAlwaysAllowed(t *testing.T) {
srv, store, _, _, jobs := newTestEnv(t)
store.services[0].UpdateMode = "auto"
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"manual"}})
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, "manual", store.modeOf(1))
}
func TestSetMode_InvalidModeAndUnknownService(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services = append(store.services, model.Service{ID: 5, Kind: protocol.KindCompose, UpdateMode: "manual", RemovedAt: ptr(time.Now())})
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"yolo"}})
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
resp, _ = postMode(t, srv, "1", url.Values{})
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
resp, _ = postMode(t, srv, "99", url.Values{"mode": {"auto"}})
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
resp, _ = postMode(t, srv, "99", url.Values{"mode": {"manual"}})
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
resp, _ = postMode(t, srv, "5", url.Values{"mode": {"auto"}})
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
resp, _ = postMode(t, srv, "abc", url.Values{"mode": {"auto"}})
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, "manual", store.modeOf(1))
}
func TestOverview_ShowsModeToggleAndAvailableBadge(t *testing.T) {
srv, store, _, _, jobs := newTestEnv(t)
store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0"
store.services[1].UpdateMode = "auto"
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonSelf}
for _, path := range []string{"/", "/fragments/services"} {
_, body := get(t, srv, path)
assert.Contains(t, body, `action="/services/1/mode"`, path)
assert.Contains(t, body, `name="csrf_token" value="csrf"`, path)
assert.Contains(t, body, `name="return" value="overview"`, path)
assert.Contains(t, body, `class="badge badge-update" title="gitea.shcizo.se/shcizo/slusk:1.5.0"`, path)
assert.Contains(t, body, `<input type="hidden" name="mode" value="manual">`, "auto services toggle to manual: "+path)
assert.Contains(t, body, `disabled title="`+model.ReasonSelf+`"`, "blocked manual services get a disabled button: "+path)
}
}
func TestServicePage_ShowsAvailableAndModeForm(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
at := time.Date(2026, 10, 12, 12, 30, 0, 0, time.UTC)
store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0"
store.services[0].AvailableSource = "webhook"
store.services[0].AvailableAt = &at
_, body := get(t, srv, "/services/1")
assert.Contains(t, body, "gitea.shcizo.se/shcizo/slusk:1.5.0")
assert.Contains(t, body, "via webhook, 2026-10-12 12:30")
assert.Contains(t, body, `action="/services/1/mode"`)
assert.Contains(t, body, `<input type="hidden" name="mode" value="auto">`)
}
func TestServicePage_AutoBlockedDisablesToggle(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonContainer}
_, body := get(t, srv, "/services/1")
assert.Contains(t, body, "Auto unavailable: "+model.ReasonContainer+".")
}