refactor: back the announcement store with plain NATS JetStream

Replace the go-micro store with a NATS JetStream key-value bucket, created via
reva's cache.NewNatsKeyValue, so the web service no longer depends on go-micro
stores. The store methods now take a context, threaded through the config.json
render path and the management handlers. Tests use a mockery mock of
jetstream.KeyValue.
This commit is contained in:
Dominik Schmidt committed 2026-07-29 10:22:43 +02:00
1 parent 7d59a83c18
commit 27bdda265f
11 files changed
+1856 -114

No files matched your search

+16 -21
View File
@@ -2,10 +2,11 @@
package announcement
import (
"context"
"encoding/json"
"errors"
microstore "go-micro.dev/v4/store"
"github.com/nats-io/nats.go/jetstream"
)
// _storeKey is the single key under which the announcement is persisted.
@@ -19,33 +20,29 @@ type Announcement struct {
InfoText string `json:"infoText"`
}
// Store persists a single announcement in a key-value store.
// Store persists a single announcement in a NATS JetStream key-value bucket.
type Store struct {
store microstore.Store
kv jetstream.KeyValue
}
// NewStore returns a new announcement Store backed by the given key-value store.
func NewStore(s microstore.Store) *Store {
return &Store{store: s}
// NewStore returns a new announcement Store backed by the given key-value bucket.
func NewStore(kv jetstream.KeyValue) *Store {
return &Store{kv: kv}
}
// Get returns the currently stored announcement. An unset announcement is returned as the zero value.
func (s *Store) Get() (Announcement, error) {
func (s *Store) Get(ctx context.Context) (Announcement, error) {
var a Announcement
records, err := s.store.Read(_storeKey)
entry, err := s.kv.Get(ctx, _storeKey)
if err != nil {
if errors.Is(err, microstore.ErrNotFound) {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return a, nil
}
return a, err
}
if len(records) == 0 {
return a, nil
}
if err := json.Unmarshal(records[0].Value, &a); err != nil {
if err := json.Unmarshal(entry.Value(), &a); err != nil {
return a, err
}
@@ -53,21 +50,19 @@ func (s *Store) Get() (Announcement, error) {
}
// Set persists the given announcement, overwriting any existing one.
func (s *Store) Set(a Announcement) error {
func (s *Store) Set(ctx context.Context, a Announcement) error {
value, err := json.Marshal(a)
if err != nil {
return err
}
return s.store.Write(&microstore.Record{
Key: _storeKey,
Value: value,
})
_, err = s.kv.Put(ctx, _storeKey, value)
return err
}
// Delete removes the stored announcement. Deleting a missing announcement is a no-op.
func (s *Store) Delete() error {
if err := s.store.Delete(_storeKey); err != nil && !errors.Is(err, microstore.ErrNotFound) {
func (s *Store) Delete(ctx context.Context) error {
if err := s.kv.Delete(ctx, _storeKey); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
return err
}
return nil
@@ -1,6 +1,7 @@
package announcement_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -10,22 +11,20 @@ import (
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
cs3permissions "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/nats-io/nats.go/jetstream"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/store"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/web/mocks"
"github.com/opencloud-eu/opencloud/services/web/pkg/announcement"
)
func newStore() *announcement.Store {
return announcement.NewStore(store.Create(store.Store("memory")))
}
func newGatewaySelector(allowed bool) pool.Selectable[gateway.GatewayAPIClient] {
code := cs3rpc.Code_CODE_OK
name := "announcement-test-allowed"
@@ -54,6 +53,7 @@ func withUser(r *http.Request) *http.Request {
func newService(store *announcement.Store, allowed bool) announcement.Service {
svc, err := announcement.NewService(announcement.ServiceOptions{}.
WithLogger(log.NopLogger()).
WithStore(store).
WithGatewaySelector(newGatewaySelector(allowed)))
Expect(err).ToNot(HaveOccurred())
@@ -61,28 +61,47 @@ func newService(store *announcement.Store, allowed bool) announcement.Service {
}
var _ = Describe("Store", func() {
It("gets, sets and deletes the announcement", func() {
s := newStore()
It("reads the stored announcement", func() {
entry := mocks.NewKeyValueEntry(GinkgoT())
entry.EXPECT().Value().Return([]byte(`{"enabled":true,"bannerText":"hello","infoText":"world"}`))
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Get(mock.Anything, "announcement").Return(entry, nil)
got, err := s.Get()
Expect(err).ToNot(HaveOccurred())
Expect(got.BannerText).To(BeEmpty())
Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello", InfoText: "world"})).To(Succeed())
got, err = s.Get()
got, err := announcement.NewStore(kv).Get(context.Background())
Expect(err).ToNot(HaveOccurred())
Expect(got.Enabled).To(BeTrue())
Expect(got.BannerText).To(Equal("hello"))
Expect(got.InfoText).To(Equal("world"))
})
Expect(s.Delete()).To(Succeed())
got, err = s.Get()
It("returns the zero value when unset", func() {
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Get(mock.Anything, "announcement").Return(nil, jetstream.ErrKeyNotFound)
got, err := announcement.NewStore(kv).Get(context.Background())
Expect(err).ToNot(HaveOccurred())
Expect(got.BannerText).To(BeEmpty())
})
It("writes the announcement", func() {
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Put(mock.Anything, "announcement", mock.Anything).Return(uint64(1), nil)
Expect(announcement.NewStore(kv).Set(context.Background(), announcement.Announcement{BannerText: "hello"})).To(Succeed())
})
It("deletes the announcement", func() {
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Delete(mock.Anything, "announcement").Return(nil)
Expect(announcement.NewStore(kv).Delete(context.Background())).To(Succeed())
})
It("treats deleting a missing announcement as a no-op", func() {
Expect(newStore().Delete()).To(Succeed())
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Delete(mock.Anything, "announcement").Return(jetstream.ErrKeyNotFound)
Expect(announcement.NewStore(kv).Delete(context.Background())).To(Succeed())
})
})
@@ -95,7 +114,7 @@ var _ = Describe("Service", func() {
It("succeeds when options are valid", func() {
_, err := announcement.NewService(announcement.ServiceOptions{}.
WithStore(newStore()).
WithStore(announcement.NewStore(mocks.NewKeyValue(GinkgoT()))).
WithGatewaySelector(newGatewaySelector(true)))
Expect(err).ToNot(HaveOccurred())
})
@@ -103,12 +122,15 @@ var _ = Describe("Service", func() {
Describe("Get", func() {
It("returns the full stored announcement when permitted", func() {
s := newStore()
Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello", InfoText: "world"})).To(Succeed())
entry := mocks.NewKeyValueEntry(GinkgoT())
entry.EXPECT().Value().Return([]byte(`{"enabled":true,"bannerText":"hello","infoText":"world"}`))
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Get(mock.Anything, "announcement").Return(entry, nil)
req := withUser(httptest.NewRequest(http.MethodGet, "/announcement", nil))
resp := httptest.NewRecorder()
newService(s, true).Get(resp, req)
newService(announcement.NewStore(kv), true).Get(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
var got announcement.Announcement
@@ -122,7 +144,7 @@ var _ = Describe("Service", func() {
req := withUser(httptest.NewRequest(http.MethodGet, "/announcement", nil))
resp := httptest.NewRecorder()
newService(newStore(), false).Get(resp, req)
newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), false).Get(resp, req)
Expect(resp.Code).To(Equal(http.StatusForbidden))
})
@@ -130,34 +152,31 @@ var _ = Describe("Service", func() {
Describe("Set", func() {
It("persists the message when permitted", func() {
s := newStore()
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Put(mock.Anything, "announcement", mock.Anything).Return(uint64(1), nil)
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"bannerText":"hello"}`)))
resp := httptest.NewRecorder()
newService(s, true).Set(resp, req)
newService(announcement.NewStore(kv), true).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusNoContent))
got, _ := s.Get()
Expect(got.BannerText).To(Equal("hello"))
})
It("is forbidden without permission", func() {
s := newStore()
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"bannerText":"hello"}`)))
resp := httptest.NewRecorder()
newService(s, false).Set(resp, req)
newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), false).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusForbidden))
got, _ := s.Get()
Expect(got.BannerText).To(BeEmpty())
})
It("rejects an invalid body", func() {
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`not json`)))
resp := httptest.NewRecorder()
newService(newStore(), true).Set(resp, req)
newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), true).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusBadRequest))
})
@@ -167,27 +186,22 @@ var _ = Describe("Service", func() {
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(body)))
resp := httptest.NewRecorder()
s := newStore()
newService(s, true).Set(resp, req)
newService(announcement.NewStore(mocks.NewKeyValue(GinkgoT())), true).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusRequestEntityTooLarge))
got, _ := s.Get()
Expect(got.BannerText).To(BeEmpty())
})
})
Describe("Set with an empty banner text", func() {
It("removes the stored announcement", func() {
s := newStore()
Expect(s.Set(announcement.Announcement{Enabled: true, BannerText: "hello"})).To(Succeed())
kv := mocks.NewKeyValue(GinkgoT())
kv.EXPECT().Delete(mock.Anything, "announcement").Return(nil)
req := withUser(httptest.NewRequest(http.MethodPut, "/announcement", strings.NewReader(`{"enabled":false,"bannerText":"","infoText":""}`)))
resp := httptest.NewRecorder()
newService(s, true).Set(resp, req)
newService(announcement.NewStore(kv), true).Set(resp, req)
Expect(resp.Code).To(Equal(http.StatusNoContent))
got, _ := s.Get()
Expect(got.BannerText).To(BeEmpty())
})
})
})
+3 -3
View File
@@ -118,7 +118,7 @@ func (s Service) Get(w http.ResponseWriter, r *http.Request) {
return
}
a, err := s.store.Get()
a, err := s.store.Get(r.Context())
if err != nil {
s.logError(r, err, "could not read announcement from store")
w.WriteHeader(http.StatusInternalServerError)
@@ -177,12 +177,12 @@ func (s Service) Set(w http.ResponseWriter, r *http.Request) {
// an announcement without a banner text is nothing to show, so remove it entirely
if body.BannerText == "" {
if err := s.store.Delete(); err != nil {
if err := s.store.Delete(r.Context()); err != nil {
s.logError(r, err, "could not delete announcement from store")
w.WriteHeader(http.StatusInternalServerError)
return
}
} else if err := s.store.Set(body); err != nil {
} else if err := s.store.Set(r.Context(), body); err != nil {
s.logError(r, err, "could not write announcement to store")
w.WriteHeader(http.StatusInternalServerError)
return
+9 -12
View File
@@ -2,7 +2,6 @@ package config
import (
"context"
"time"
"github.com/opencloud-eu/opencloud/pkg/shared"
)
@@ -30,18 +29,16 @@ type Config struct {
Context context.Context `yaml:"-"`
}
// Store configures the persistent store used to keep runtime managed web settings, e.g. the announcement banner.
// Store configures the NATS JetStream key-value store used to keep runtime managed web settings,
// e.g. the announcement banner.
type Store struct {
Store string `yaml:"store" env:"OC_PERSISTENT_STORE;WEB_STORE" desc:"The type of the store. Supported values are: 'memory', 'nats-js-kv', 'redis-sentinel', 'noop'. See the text description for details." introductionVersion:"%%NEXT%%"`
Nodes []string `yaml:"nodes" env:"OC_PERSISTENT_STORE_NODES;WEB_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
Database string `yaml:"database" env:"WEB_STORE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"%%NEXT%%"`
Table string `yaml:"table" env:"WEB_STORE_TABLE" desc:"The database table the store should use." introductionVersion:"%%NEXT%%"`
TTL time.Duration `yaml:"ttl" env:"OC_PERSISTENT_STORE_TTL;WEB_STORE_TTL" desc:"Time to live for entries in the store. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;WEB_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"`
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;WEB_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"`
EnableTLS bool `yaml:"enable_tls" env:"OC_PERSISTENT_STORE_ENABLE_TLS;WEB_STORE_ENABLE_TLS" desc:"Enable TLS for the connection to the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"`
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_PERSISTENT_STORE_TLS_INSECURE;WEB_STORE_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"%%NEXT%%"`
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_PERSISTENT_STORE_TLS_ROOT_CA_CERTIFICATE;WEB_STORE_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided WEB_STORE_TLS_INSECURE will be seen as false." introductionVersion:"%%NEXT%%"`
Nodes []string `yaml:"nodes" env:"OC_PERSISTENT_STORE_NODES;WEB_STORE_NODES" desc:"A list of nodes to access the NATS JetStream store. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
Database string `yaml:"database" env:"WEB_STORE_DATABASE" desc:"The bucket name the store should use." introductionVersion:"%%NEXT%%"`
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;WEB_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store." introductionVersion:"%%NEXT%%"`
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;WEB_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store." introductionVersion:"%%NEXT%%"`
EnableTLS bool `yaml:"enable_tls" env:"OC_PERSISTENT_STORE_ENABLE_TLS;WEB_STORE_ENABLE_TLS" desc:"Enable TLS for the connection to the store." introductionVersion:"%%NEXT%%"`
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_PERSISTENT_STORE_TLS_INSECURE;WEB_STORE_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"%%NEXT%%"`
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_PERSISTENT_STORE_TLS_ROOT_CA_CERTIFICATE;WEB_STORE_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided WEB_STORE_TLS_INSECURE will be seen as false." introductionVersion:"%%NEXT%%"`
}
// Asset defines the available asset configuration.
@@ -86,11 +86,8 @@ func DefaultConfig() *config.Config {
},
GatewayAddress: "eu.opencloud.api.gateway",
Store: config.Store{
Store: "nats-js-kv",
Nodes: []string{"127.0.0.1:9233"},
Database: "web",
Table: "",
TTL: 0,
},
Web: config.Web{
ThemeServer: "https://localhost:9200",
+15 -14
View File
@@ -6,9 +6,8 @@ import (
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/store"
"github.com/opencloud-eu/reva/v2/pkg/storage/cache"
"go-micro.dev/v4"
microstore "go-micro.dev/v4/store"
"github.com/opencloud-eu/opencloud/pkg/cors"
"github.com/opencloud-eu/opencloud/pkg/middleware"
@@ -79,18 +78,20 @@ func Server(opts ...Option) (http.Service, error) {
fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/themes"),
)
// persistent store for runtime managed web settings, e.g. the announcement banner
announcementStore := announcement.NewStore(store.Create(
store.Store(options.Config.Store.Store),
store.TTL(options.Config.Store.TTL),
microstore.Nodes(options.Config.Store.Nodes...),
microstore.Database(options.Config.Store.Database),
microstore.Table(options.Config.Store.Table),
store.Authentication(options.Config.Store.AuthUsername, options.Config.Store.AuthPassword),
store.TLSEnabled(options.Config.Store.EnableTLS),
store.TLSInsecure(options.Config.Store.TLSInsecure),
store.TLSRootCA(options.Config.Store.TLSRootCACertificate),
))
// NATS JetStream key-value store for runtime managed web settings, e.g. the announcement banner
kv, err := cache.NewNatsKeyValue(cache.Config{
Nodes: options.Config.Store.Nodes,
Database: options.Config.Store.Database,
AuthUsername: options.Config.Store.AuthUsername,
AuthPassword: options.Config.Store.AuthPassword,
TLSEnabled: options.Config.Store.EnableTLS,
TLSInsecure: options.Config.Store.TLSInsecure,
TLSRootCACertificate: options.Config.Store.TLSRootCACertificate,
}, &options.Logger.Logger)
if err != nil {
return http.Service{}, fmt.Errorf("could not initialize announcement store: %w", err)
}
announcementStore := announcement.NewStore(kv)
handle, err := svc.NewService(
svc.Logger(options.Logger),
+7 -6
View File
@@ -1,6 +1,7 @@
package svc
import (
"context"
"encoding/json"
"io/fs"
"net/http"
@@ -136,7 +137,7 @@ func (p Web) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.mux.ServeHTTP(w, r)
}
func (p Web) getPayload() (payload []byte, err error) {
func (p Web) getPayload(ctx context.Context) (payload []byte, err error) {
// render dynamically using a copy of the config, so per-request values (e.g. the
// announcement) are not written into the shared config concurrently.
webConfig := p.config.Web.Config
@@ -160,7 +161,7 @@ func (p Web) getPayload() (payload []byte, err error) {
// the runtime store is authoritative once it manages an announcement (enabled or explicitly
// disabled); only when it holds nothing do we keep a statically configured one
// (web.config.options.announcement)
if a, managed := p.managedAnnouncement(); managed {
if a, managed := p.managedAnnouncement(ctx); managed {
webConfig.Options.Announcement = a
}
@@ -171,12 +172,12 @@ func (p Web) getPayload() (payload []byte, err error) {
// bool reports whether the store manages one at all: when true, the returned value (nil for an
// explicitly disabled announcement) overrides any static config; when false, the store holds
// nothing and a statically configured announcement is kept.
func (p Web) managedAnnouncement() (*config.Announcement, bool) {
func (p Web) managedAnnouncement(ctx context.Context) (*config.Announcement, bool) {
if p.announcementStore == nil {
return nil, false
}
a, err := p.announcementStore.Get()
a, err := p.announcementStore.Get(ctx)
if err != nil {
p.logger.Error().Err(err).Msg("could not read announcement from store")
return nil, false
@@ -197,8 +198,8 @@ func (p Web) managedAnnouncement() (*config.Announcement, bool) {
}
// Config implements the Service interface.
func (p Web) Config(w http.ResponseWriter, _ *http.Request) {
payload, err := p.getPayload()
func (p Web) Config(w http.ResponseWriter, r *http.Request) {
payload, err := p.getPayload(r.Context())
if err != nil {
http.Error(w, ErrConfigInvalid, http.StatusUnprocessableEntity)
return
+27 -16
View File
@@ -1,56 +1,67 @@
package svc
import (
"context"
"testing"
"github.com/opencloud-eu/reva/v2/pkg/store"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/web/mocks"
"github.com/opencloud-eu/opencloud/services/web/pkg/announcement"
"github.com/opencloud-eu/opencloud/services/web/pkg/config"
)
func TestManagedAnnouncement(t *testing.T) {
newWeb := func(a *announcement.Store) Web {
return Web{logger: log.NopLogger(), announcementStore: a}
newWeb := func(store *announcement.Store) Web {
return Web{logger: log.NopLogger(), announcementStore: store}
}
newStore := func() *announcement.Store {
return announcement.NewStore(store.Create(store.Store("memory")))
// storeReturning builds a store whose backing bucket returns the given JSON for the announcement key.
storeReturning := func(t *testing.T, value string) *announcement.Store {
entry := mocks.NewKeyValueEntry(t)
entry.EXPECT().Value().Return([]byte(value))
kv := mocks.NewKeyValue(t)
kv.EXPECT().Get(mock.Anything, "announcement").Return(entry, nil)
return announcement.NewStore(kv)
}
// emptyStore builds a store whose backing bucket has no announcement.
emptyStore := func(t *testing.T) *announcement.Store {
kv := mocks.NewKeyValue(t)
kv.EXPECT().Get(mock.Anything, "announcement").Return(nil, jetstream.ErrKeyNotFound)
return announcement.NewStore(kv)
}
t.Run("not managed when there is no store, so a static config is kept", func(t *testing.T) {
a, managed := newWeb(nil).managedAnnouncement()
a, managed := newWeb(nil).managedAnnouncement(context.Background())
require.Nil(t, a)
require.False(t, managed)
})
t.Run("not managed when the store is empty, so a static config is kept", func(t *testing.T) {
a, managed := newWeb(newStore()).managedAnnouncement()
a, managed := newWeb(emptyStore(t)).managedAnnouncement(context.Background())
require.Nil(t, a)
require.False(t, managed)
})
t.Run("managed but nil when disabled, so a static config is cleared", func(t *testing.T) {
s := newStore()
require.NoError(t, s.Set(announcement.Announcement{Enabled: false, BannerText: "hi", InfoText: "info"}))
a, managed := newWeb(s).managedAnnouncement()
s := storeReturning(t, `{"enabled":false,"bannerText":"hi","infoText":"info"}`)
a, managed := newWeb(s).managedAnnouncement(context.Background())
require.Nil(t, a)
require.True(t, managed)
})
t.Run("not managed when enabled but the banner text is empty", func(t *testing.T) {
s := newStore()
require.NoError(t, s.Set(announcement.Announcement{Enabled: true, InfoText: "info"}))
a, managed := newWeb(s).managedAnnouncement()
s := storeReturning(t, `{"enabled":true,"bannerText":"","infoText":"info"}`)
a, managed := newWeb(s).managedAnnouncement(context.Background())
require.Nil(t, a)
require.False(t, managed)
})
t.Run("managed with banner and info text when enabled with a banner text", func(t *testing.T) {
s := newStore()
require.NoError(t, s.Set(announcement.Announcement{Enabled: true, BannerText: "hi", InfoText: "info"}))
a, managed := newWeb(s).managedAnnouncement()
s := storeReturning(t, `{"enabled":true,"bannerText":"hi","infoText":"info"}`)
a, managed := newWeb(s).managedAnnouncement(context.Background())
require.Equal(t, &config.Announcement{BannerText: "hi", InfoText: "info"}, a)
require.True(t, managed)
})