Offer archive expiry choices on the target forms, show plain units (closes #396)
check / check (push) Waiting to run

A database target's archive expiry was typed by hand as never or a raw duration such as 720h, and the target list showed it back raw. Adding or editing a database target now offers the new-webhook page's list of choices (never, 1h, 12h, 24h, 30d, 90d, 365d), defined once and shared by all three forms. The edit form starts on the stored expiry, or on the submitted one after a refused save; a stored value outside the choices is listed under its own value, so saving unchanged keeps it. The target list shows the expiry in plain units: "30 days", "12 hours", "never".

Model: opus-5-5
This commit was merged in pull request #479.
This commit is contained in:
2026-10-03 01:16:14 +02:00
parent f282c6363d
commit 3489d6909a
15 changed files with 366 additions and 52 deletions
+58
View File
@@ -0,0 +1,58 @@
package handlers
const (
// archiveExpiryNever is the archive expiry that keeps archived
// events forever. A stored empty expiry means the same.
archiveExpiryNever = "never"
// tmplKeyArchiveExpiryChoices is the template data key for the
// entries of a page's archive expiry select.
tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices"
)
// archiveExpiryChoice is one entry of a database target's archive
// expiry select: the expiry stored, the label shown, and whether the
// select starts on it.
type archiveExpiryChoice struct {
Value string
Label string
Selected bool
}
// archiveExpiryChoices lists the archive expiries offered by the new
// webhook page, the add target form and the target edit form.
func archiveExpiryChoices() []archiveExpiryChoice {
return []archiveExpiryChoice{
{Value: archiveExpiryNever, Label: archiveExpiryNever},
{Value: "1h", Label: "1h"},
{Value: "12h", Label: "12h"},
{Value: "24h", Label: "24h"},
{Value: "720h", Label: "30d"},
{Value: "2160h", Label: "90d"},
{Value: "8760h", Label: "365d"},
}
}
// archiveExpiryOptions returns the choices with expiry selected; an
// empty expiry selects never. An expiry that is not one of the
// choices comes first as its own selected entry, so saving the form
// unchanged keeps it.
func archiveExpiryOptions(expiry string) []archiveExpiryChoice {
if expiry == "" {
expiry = archiveExpiryNever
}
options := archiveExpiryChoices()
for i := range options {
if options[i].Value == expiry {
options[i].Selected = true
return options
}
}
own := archiveExpiryChoice{Value: expiry, Label: expiry, Selected: true}
return append([]archiveExpiryChoice{own}, options...)
}
+166
View File
@@ -0,0 +1,166 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// expiryNever is the archive expiry that keeps archived events
// forever.
const expiryNever = "never"
// matched returns what the one group of pattern matched in page, at
// each match.
func matched(pattern, page string) []string {
matches := regexp.MustCompile(pattern).FindAllStringSubmatch(page, -1)
groups := make([]string, 0, len(matches))
for _, m := range matches {
groups = append(groups, m[1])
}
return groups
}
// expiryShown returns the archive expiries the webhook page's target
// list shows.
func expiryShown(
t *testing.T, env *sourceTestEnv, webhookID string,
) []string {
t.Helper()
w := httptest.NewRecorder()
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
t, "/hook/"+webhookID, env.cookies,
map[string]string{sourceIDParam: webhookID},
))
require.Equal(t, http.StatusOK, w.Code)
return matched(
`Archive Expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
)
}
// expirySelected returns the target edit page and the expiries its
// select starts on.
func expirySelected(
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
) (string, []string) {
t.Helper()
w := serveTarget(
env, http.MethodGet,
"/hook/"+webhookID+"/targets/"+targetID+"/edit", nil,
)
require.Equal(t, http.StatusOK, w.Code)
page := w.Body.String()
return page, matched(`<option value="([^"]*)" selected>`, page)
}
// TestArchiveExpiryChoices adds a database target with each archive
// expiry the forms offer, and checks that it is stored as chosen,
// shown in plain units in the target list, and that the target edit
// form starts on it.
func TestArchiveExpiryChoices(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
choices := []struct{ value, shown string }{
{expiryNever, expiryNever},
{"1h", "1 hour"},
{"12h", "12 hours"},
{"24h", "1 day"},
{"720h", "30 days"},
{"2160h", "90 days"},
{"8760h", "365 days"},
}
for _, choice := range choices {
t.Run(choice.value, func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
form.Set("expiry", choice.value)
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(
t, `{"expiry":"`+choice.value+`"}`, targets[0].Config,
)
assert.Equal(
t, []string{choice.shown},
expiryShown(t, env, webhook.ID),
)
_, selected := expirySelected(t, env, webhook.ID, targets[0].ID)
assert.Equal(t, []string{choice.value}, selected)
})
}
}
// TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a
// database target whose stored expiry is empty, which selects never,
// and of one whose expiry is not one of the choices, which is listed
// first as its own selected entry and saved unchanged.
func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
empty := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
)
_, selected := expirySelected(t, env, webhook.ID, empty.ID)
assert.Equal(t, []string{expiryNever}, selected)
webhook = seedWebhookWithRetention(t, env.db, 30)
unlisted := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"expiry":"36h"}`,
)
assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID))
page, selected := expirySelected(t, env, webhook.ID, unlisted.ID)
assert.Equal(t, []string{"36h"}, selected)
assert.Regexp(
t,
`<select id="expiry" name="expiry" class="input">\s*`+
`<option value="36h" selected>36h</option>\s*`+
`<option value="never">never</option>`,
page,
)
assert.Contains(t, page, `<option value="8760h">365d</option>`)
form := url.Values{}
form.Set("name", unlisted.Name)
form.Set("expiry", "36h")
w := submitTargetEdit(env, webhook.ID, unlisted.ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.JSONEq(
t, `{"expiry":"36h"}`, storedTarget(t, env, unlisted.ID).Config,
)
}
+1 -1
View File
@@ -234,7 +234,7 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
assert.NotContains(t, body, "sekrit")
assert.Contains(t, body, "Archive Expiry")
assert.Contains(t, body, "720h")
assert.Contains(t, body, "30 days")
// An unknown type gets the neutral placeholder, never the
// stored blob.
+6
View File
@@ -312,6 +312,9 @@ func newSourceFormData(
tmplKeyError: errMsg,
"Form": in,
"DefaultRetentionDays": database.DefaultRetentionDays,
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(
in.ArchiveExpiry,
),
}
}
@@ -623,6 +626,9 @@ func (h *Handlers) renderSourceDetail(
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
tmplKeyTargetForm: targetForm,
"TargetError": targetErr,
// The add target form's select starts on its expiry
// through Alpine, so no choice is selected here.
tmplKeyArchiveExpiryChoices: archiveExpiryChoices(),
}
status := http.StatusOK
+4 -3
View File
@@ -238,9 +238,10 @@ func (h *Handlers) renderTargetEdit(
Type: target.Type,
Active: target.Active,
},
tmplKeyTargetForm: form,
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
tmplKeyError: errMsg,
tmplKeyTargetForm: form,
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
tmplKeyError: errMsg,
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(form.Expiry),
}
h.renderTemplateStatus(w, r, targetEditTemplate, data, status)
+7 -3
View File
@@ -613,12 +613,16 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
page := w.Body.String()
assert.Contains(t, page, `class="alert-error">`+tc.reason)
// headers is the form's one textarea; every other field is
// an input.
// headers is the form's one textarea and expiry its one
// select; every other field is an input.
for field := range form {
shown := `name="` + field + `" value="` + form.Get(field) + `"`
if field == "headers" {
switch field {
case "headers":
shown = ">" + form.Get(field) + "</textarea>"
case "expiry":
shown = `<option value="` + form.Get(field) + `" selected>`
}
assert.Contains(t, page, shown)