feat(announcements): reuse #419's mechanism for the update-check notice

Fourth piece of #591 — the "minimal and future-proof at once" move from
the design doc: no new notice UI, just one new entry in the #419
announcements list, which is already rendered in both the admin UI and the
player and already has per-ID dismissal.

Added Announcement.MessageFunc/DismissKeyFunc (nil = use the static
Message/ID, as before, so the existing #419 entry is unaffected) since
this entry's text names a specific version and its dismissal must be
per-version — dismissing the notice for v1.2.0 must not suppress a later
notice for v1.3.0. HandleListAnnouncements/HandleDismissAnnouncement now
compute the effective key through Announcement.dismissKey(s) rather than
reading the static ID field directly.

Refs #591
This commit is contained in:
Tobias Gesellchen
2026-08-09 01:22:03 +02:00
parent 1248a0bd1c
commit 0c343f85c7
2 changed files with 167 additions and 8 deletions
+59 -8
View File
@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
@@ -22,12 +23,42 @@ const (
// _/i419/design-admin-area-auth-gate.md. ShowWhile lets an entry key off
// live server state (e.g. "only while the admin-area gate hasn't been
// decided yet"); nil means always show (until dismissed).
//
// MessageFunc and DismissKeyFunc (added for #591,
// _/i591/design-update-check.md) are the dynamic counterparts of Message
// and ID: nil means "use the static field", as before; set means "compute
// it from live state". The update-check notice needs both — its text names
// a specific version, and dismissing the notice for v1.2.0 must not
// suppress a later notice for v1.3.0, so its dismissal key has to change
// with the detected version.
type Announcement struct {
ID string
Message string
Level string
Targets []string
ShowWhile func(*Server) bool
ID string
Message string
MessageFunc func(*Server) string
Level string
Targets []string
ShowWhile func(*Server) bool
DismissKeyFunc func(*Server) string
}
// message returns the effective text: MessageFunc(s) if set, else the
// static Message.
func (a Announcement) message(s *Server) string {
if a.MessageFunc != nil {
return a.MessageFunc(s)
}
return a.Message
}
// dismissKey returns the effective dismissal/DTO id: DismissKeyFunc(s) if
// set, else the static ID.
func (a Announcement) dismissKey(s *Server) string {
if a.DismissKeyFunc != nil {
return a.DismissKeyFunc(s)
}
return a.ID
}
// announcements is the full, in-code list. announcementTargetChooser is
@@ -48,6 +79,25 @@ var announcements = []Announcement{
return s.AdminAreaAuthMode() == ""
},
},
{
ID: "update-available",
Level: "info",
Targets: []string{announcementTargetApp, announcementTargetAdmin},
ShowWhile: func(s *Server) bool {
return s.UpdateCheckResult().Available
},
MessageFunc: func(s *Server) string {
r := s.UpdateCheckResult()
return fmt.Sprintf("AfterTouch %s is available (you're on %s). %s",
r.LatestVersion, r.CurrentVersion, r.ReleaseURL)
},
// Per-version, not per-family: dismissing the notice for one version
// must not silently suppress a later, different version's notice.
DismissKeyFunc: func(s *Server) string {
return "update-available-" + s.UpdateCheckResult().LatestVersion
},
},
}
// announcementDTO is the JSON shape returned by HandleListAnnouncements —
@@ -97,11 +147,12 @@ func (s *Server) HandleListAnnouncements(w http.ResponseWriter, r *http.Request)
continue
}
if s.IsAnnouncementDismissed(a.ID) {
key := a.dismissKey(s)
if s.IsAnnouncementDismissed(key) {
continue
}
active = append(active, announcementDTO{ID: a.ID, Message: a.Message, Level: a.Level})
active = append(active, announcementDTO{ID: key, Message: a.message(s), Level: a.Level})
}
w.Header().Set("Content-Type", "application/json")
@@ -124,7 +175,7 @@ func (s *Server) HandleDismissAnnouncement(w http.ResponseWriter, r *http.Reques
found := false
for _, a := range announcements {
if a.ID == id {
if a.dismissKey(s) == id {
found = true
break
}
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
"github.com/go-chi/chi/v5"
)
@@ -163,3 +164,110 @@ func TestHandleDismissAnnouncement_Success(t *testing.T) {
t.Errorf("expected the notice to be gone from the list after dismissal, got %+v", active)
}
}
// newServerWithUpdateAvailable builds a Server whose registered
// updatecheck.Checker reports a newer version than currentVersion, via the
// same persisted-state-seeding path a real restart would use (not a mock —
// exercises the real NewChecker/UpdateCheckResult round trip).
func newServerWithUpdateAvailable(t *testing.T, currentVersion, latestVersion string) *Server {
t.Helper()
s := newAnnouncementsTestServer(t)
ds := datastore.NewDataStore(t.TempDir())
if err := ds.SaveUpdateCheckState(datastore.UpdateCheckState{
LastCheckedAt: "2026-08-09T00:00:00Z",
LastSeenVersion: latestVersion,
}); err != nil {
t.Fatalf("Failed to seed update-check state: %v", err)
}
s.SetUpdateChecker(updatecheck.NewChecker(ds, "owner/repo", currentVersion))
return s
}
// TestHandleListAnnouncements_UpdateAvailable is the regression test for
// #591's reuse of the #419 announcements mechanism: the update-available
// entry's dynamic message/target/dismissal behavior end to end.
func TestHandleListAnnouncements_UpdateAvailable(t *testing.T) {
t.Run("visible for both admin and app targets when available", func(t *testing.T) {
s := newServerWithUpdateAvailable(t, "v1.0.0", "v1.2.0")
for _, target := range []string{announcementTargetAdmin, announcementTargetApp} {
_, active := listAnnouncements(t, s, target)
var found *announcementDTO
for i := range active {
if active[i].ID == "update-available-v1.2.0" {
found = &active[i]
}
}
if found == nil {
t.Fatalf("target=%s: expected an update-available-v1.2.0 entry, got %+v", target, active)
}
if found.Message == "" {
t.Errorf("target=%s: expected a non-empty dynamic message", target)
}
}
})
t.Run("not visible when already up to date", func(t *testing.T) {
s := newServerWithUpdateAvailable(t, "v1.2.0", "v1.2.0")
_, active := listAnnouncements(t, s, announcementTargetAdmin)
if containsAnnouncementID(active, "update-available-v1.2.0") {
t.Errorf("expected no update-available entry when up to date, got %+v", active)
}
})
t.Run("dismissing one version does not suppress a later version", func(t *testing.T) {
s := newServerWithUpdateAvailable(t, "v1.0.0", "v1.2.0")
if err := s.RecordDismissal("update-available-v1.2.0"); err != nil {
t.Fatalf("RecordDismissal failed: %v", err)
}
_, active := listAnnouncements(t, s, announcementTargetAdmin)
if containsAnnouncementID(active, "update-available-v1.2.0") {
t.Error("expected the v1.2.0 notice to be dismissed")
}
// A later check finds a newer version still: must reappear under a
// DIFFERENT dismissal key, not stay suppressed.
newDS := datastore.NewDataStore(t.TempDir())
if err := newDS.SaveUpdateCheckState(datastore.UpdateCheckState{
LastCheckedAt: "2026-08-10T00:00:00Z",
LastSeenVersion: "v1.3.0",
}); err != nil {
t.Fatalf("Failed to seed newer state: %v", err)
}
s.SetUpdateChecker(updatecheck.NewChecker(newDS, "owner/repo", "v1.0.0"))
_, active = listAnnouncements(t, s, announcementTargetAdmin)
if !containsAnnouncementID(active, "update-available-v1.3.0") {
t.Errorf("expected the v1.3.0 notice to appear despite v1.2.0 being dismissed, got %+v", active)
}
})
}
func TestHandleDismissAnnouncement_UpdateAvailable(t *testing.T) {
s := newServerWithUpdateAvailable(t, "v1.0.0", "v1.2.0")
r := chi.NewRouter()
r.Post("/api/announcements/{id}/dismiss", s.HandleDismissAnnouncement)
req := httptest.NewRequest(http.MethodPost, "/api/announcements/update-available-v1.2.0/dismiss", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
_, active := listAnnouncements(t, s, announcementTargetAdmin)
if containsAnnouncementID(active, "update-available-v1.2.0") {
t.Errorf("expected the notice to be gone after dismissal, got %+v", active)
}
}