mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user