diff --git a/pkg/service/handlers/handlers_announcements.go b/pkg/service/handlers/handlers_announcements.go index ec23876..20d6a0d 100644 --- a/pkg/service/handlers/handlers_announcements.go +++ b/pkg/service/handlers/handlers_announcements.go @@ -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 } diff --git a/pkg/service/handlers/handlers_announcements_test.go b/pkg/service/handlers/handlers_announcements_test.go index 9afe391..8436afd 100644 --- a/pkg/service/handlers/handlers_announcements_test.go +++ b/pkg/service/handlers/handlers_announcements_test.go @@ -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) + } +}