From c908ed29f77385a3fa94e0021fe89b0fb9eff5fe Mon Sep 17 00:00:00 2001 From: Vuks69 <51289041+Vuks69@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:41:02 +0100 Subject: [PATCH] feat(config): settings framework Add a per-scope settings framework the config feature builds on: a registry of ScopeDesc values, generic Get/Update/Document/Reset, a per-scope cache, and a default-overlay that decodes a stored document onto a fresh copy of the registered defaults. A guild that configured nothing reads the defaults; a document that will not decode or fails validation logs and falls back rather than breaking a command. Add the db layer that owns the guild settings_json document: GetGuildDocument, SetGuildDocument (whole-document replace), and the json_set/json_remove role-path helpers, so concurrent role edits from different admins cannot clobber each other. --- internal/config/config.go | 448 +++++++++++++++++++++++ internal/config/config_test.go | 636 +++++++++++++++++++++++++++++++++ internal/db/settings.go | 103 ++++++ internal/db/settings_test.go | 98 +++++ 4 files changed, 1285 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/db/settings.go create mode 100644 internal/db/settings_test.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e05ef87 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,448 @@ +// Package config resolves the settings a guild has configured for the bot, and +// backs the /config command that edits them. Each configurable area registers a +// ScopeDesc before the bot starts, so it declares its own settings shape, +// defaults and validation while the framework owns storage, caching and the +// fallback. +// +// Reads fail closed: the bot opens its database after the gateway is already +// open and leaves no handle until then, so a read before it opens returns an +// error rather than defaults that may be stale. A guild that has configured +// nothing still reads the registered defaults; only a database that will not +// open is a failure. +package config + +import ( + "bytes" + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "reflect" + "slices" + "sync" + "time" + + "github.com/Neon-Genesis-Linux/pen-bot/internal/db" +) + +const ( + cacheKeyPrefix = "cfg:v1" + + cacheTTLEnv = "CONFIG_CACHE_TTL" + cacheTTLMinutes = 5 +) + +// errNoDatabase reports that the bot holds no database handle yet, so a read or +// a write has nowhere to go. +var errNoDatabase = errors.New("database is not available") + +// ErrScopeNotResettable reports that a scope has no single way back to its +// defaults, so it cannot be reset in one step. Its message is written for the +// caller, who may show it as it stands. +var ErrScopeNotResettable = errors.New("this scope cannot be reset in one step") + +// ScopeDesc declares one configurable area's settings shape, the value a guild +// that has configured nothing sees, and an optional check run against every +// value read or written. +// +// The defaults are overlaid onto what is stored, so a key the stored document +// leaves out keeps its default, and a setting whose zero value carries meaning +// should be declared as a pointer. A scope whose settings include a map should +// default that map to empty, or the defaults' entries reappear in every guild. +type ScopeDesc[T any] struct { + // Key names the scope in storage and in /config. It is the top-level slash + // command path its module answers to, so one scope covers a command and all + // of its subcommands. + Key string + // Module names the package or feature area the scope belongs to, which + // /config view groups by. + Module string + // Summary is the one-line description /config view shows. + Summary string + // Defaults is what a guild that has never configured the scope reads back. + Defaults T + // Validate rejects a value that should not be stored. A nil Validate + // accepts everything. + Validate func(T) error +} + +// ScopeInfo describes a registered scope without its settings. +type ScopeInfo struct { + Key string + Module string + Summary string +} + +// registeredScope holds a scope's settings type erased, so that Get and Update +// can find it by key and refuse a caller asking for the wrong type. +type registeredScope struct { + key string + module string + summary string + + // typ is the settings type the scope was registered with. Get and Update + // compare it against the type their caller asked for. + typ reflect.Type + + // defaultsJSON is the registered defaults encoded, decoded afresh for each + // read rather than copied from the registered value every reader shares. + defaultsJSON []byte + + // load reads the stored document, reporting errNoDatabase when there is no + // handle. save replaces it. + load func(ctx context.Context, guildID string) (json.RawMessage, error) + save func(ctx context.Context, guildID string, raw json.RawMessage) error + + // resolve overlays raw onto a private copy of the defaults into dst, which + // must be a pointer to the scope's settings type. + resolve func(raw json.RawMessage, dst any) error + // render does the same and encodes the result, for a caller that does not + // know the scope's settings type. + render func(raw json.RawMessage) (json.RawMessage, error) + // validate reports whether a settings value may be stored. + validate func(value any) error + // reset drops the stored document, reporting whether there was one. + reset func(ctx context.Context, guildID string) (bool, error) +} + +// storage tells a scope where its document lives. The guild-wide scope stores it +// in the guild's settings document; any other scope stores its own elsewhere, +// but load, save and reset share this shape, so nothing else about a scope +// depends on which store its document lives in. +type storage struct { + load func(ctx context.Context, guildID string) (json.RawMessage, error) + save func(ctx context.Context, guildID string, raw json.RawMessage) error + reset func(ctx context.Context, guildID string) (bool, error) +} + +var ( + registryMu sync.RWMutex + registry = make(map[string]*registeredScope) +) + +// register adds a scope to the registry. It reports an error when the key is +// empty, the defaults cannot be encoded, or the key is already taken. +func register[T any](d ScopeDesc[T], store storage) error { + if d.Key == "" { + return fmt.Errorf("scope key required") + } + if store.load == nil || store.save == nil || store.reset == nil { + return fmt.Errorf("scope %q needs storage for load, save and reset", d.Key) + } + + defaultsJSON, err := json.Marshal(d.Defaults) + if err != nil { + return fmt.Errorf("scope %q defaults are not encodable: %w", d.Key, err) + } + + scope := ®isteredScope{ + key: d.Key, + module: d.Module, + summary: d.Summary, + typ: reflect.TypeFor[T](), + defaultsJSON: defaultsJSON, + load: store.load, + save: store.save, + reset: store.reset, + resolve: func(raw json.RawMessage, dst any) error { + value, ok := dst.(*T) + if !ok { + return fmt.Errorf("config scope %q expects %s", d.Key, reflect.TypeFor[T]()) + } + overlaid, err := overlay[T](defaultsJSON, raw) + *value = overlaid + return err + }, + render: func(raw json.RawMessage) (json.RawMessage, error) { + overlaid, err := overlay[T](defaultsJSON, raw) + if err != nil { + return nil, err + } + return json.Marshal(overlaid) + }, + validate: func(value any) error { + if d.Validate == nil { + return nil + } + typed, ok := value.(T) + if !ok { + return fmt.Errorf("config scope %q expects %s", d.Key, reflect.TypeFor[T]()) + } + return d.Validate(typed) + }, + } + + registryMu.Lock() + defer registryMu.Unlock() + if _, exists := registry[scope.key]; exists { + return fmt.Errorf("scope %q is already registered", d.Key) + } + registry[scope.key] = scope + return nil +} + +// RegisterScope is the public door into the framework: it registers a scope so +// Get, Update, Document and Reset can serve it by key. The load, save and reset +// functions are the scope's storage — load reads its document for a guild, save +// replaces it and reset drops it, reporting whether there was one. +func RegisterScope[T any]( + desc ScopeDesc[T], + load func(ctx context.Context, guildID string) (json.RawMessage, error), + save func(ctx context.Context, guildID string, raw json.RawMessage) error, + reset func(ctx context.Context, guildID string) (bool, error), +) error { + return register(desc, storage{load: load, save: save, reset: reset}) +} + +// Get returns a guild's settings for a scope with the registered defaults +// overlaid, and reports an error only when the scope is unknown, its type does +// not match the caller's, or the database could not be read. A stored document +// that cannot be decoded or does not pass validation is logged and replaced by +// the defaults, so a bad row cannot break a command. +func Get[T any](ctx context.Context, key, guildID string) (T, error) { + scope, err := scopeByType[T](key) + if err != nil { + var zero T + return zero, err + } + + raw, err := scope.read(ctx, guildID) + if err != nil { + var zero T + return zero, err + } + + value, ok := decode[T](scope, raw, guildID) + if !ok { + return defaultValue[T](scope), nil + } + return value, nil +} + +// Update applies mutate to a guild's settings for a scope and stores the result. +// It reads past the cache, because writing a document built from a copy that +// predates the call would undo changes the caller never saw, and drops the +// cache entry once the write lands. +// +// mutate reports an error to abandon the write, which is how a caller validates +// something it could not express through the scope's Validate. +func Update[T any](ctx context.Context, key, guildID string, mutate func(*T) error) error { + if mutate == nil { + return fmt.Errorf("scope %q needs a mutate function", key) + } + + scope, err := scopeByType[T](key) + if err != nil { + return err + } + + raw, err := scope.readFresh(ctx, guildID) + if err != nil { + return err + } + + value, ok := decode[T](scope, raw, guildID) + if !ok { + value = defaultValue[T](scope) + } + + if err := mutate(&value); err != nil { + return err + } + if err := scope.validate(value); err != nil { + return err + } + + encoded, err := json.Marshal(value) + if err != nil { + return err + } + if err := scope.save(ctx, guildID, encoded); err != nil { + return err + } + + db.DeleteCacheEntry(cacheKey(guildID, scope.key)) + return nil +} + +// Document returns a scope's settings for a guild as JSON with the registered +// defaults overlaid. It is for a caller that shows the settings rather than acts +// on them, and so does not know the scope's settings type. +func Document(ctx context.Context, key, guildID string) (json.RawMessage, error) { + scope, err := scopeByKey(key) + if err != nil { + return nil, err + } + + raw, err := scope.read(ctx, guildID) + if err != nil { + return nil, err + } + document, err := scope.render(raw) + if err != nil { + slog.Warn("config unreadable, showing defaults", + "scope", key, "guild_id", guildID, slog.Any("error", err)) + return scope.render(nil) + } + return document, nil +} + +// Reset drops a guild's stored settings for a scope so that it reads back as the +// registered defaults, reporting whether anything was stored. +func Reset(ctx context.Context, key, guildID string) (bool, error) { + scope, err := scopeByKey(key) + if err != nil { + return false, err + } + + reset, err := scope.reset(ctx, guildID) + if err != nil || !reset { + return reset, err + } + + db.DeleteCacheEntry(cacheKey(guildID, scope.key)) + return true, nil +} + +// ScopeByKey returns a registered scope's metadata without its settings. The +// returned ScopeInfo always carries the key that was asked for, so a caller can +// name it in the message it shows when the lookup fails. +func ScopeByKey(key string) (ScopeInfo, bool) { + scope, err := scopeByKey(key) + if err != nil { + return ScopeInfo{Key: key, Module: "", Summary: ""}, false + } + return ScopeInfo{ + Key: scope.key, + Module: scope.module, + Summary: scope.summary, + }, true +} + +// Scopes returns every registered scope ordered by module and then key, for +// display. +func Scopes() []ScopeInfo { + registryMu.RLock() + defer registryMu.RUnlock() + + infos := make([]ScopeInfo, 0, len(registry)) + for _, scope := range registry { + infos = append(infos, ScopeInfo{ + Key: scope.key, + Module: scope.module, + Summary: scope.summary, + }) + } + slices.SortFunc(infos, func(a, b ScopeInfo) int { + return cmp.Or(cmp.Compare(a.Module, b.Module), cmp.Compare(a.Key, b.Key)) + }) + return infos +} + +// read returns a scope's stored document for a guild, preferring the cache. A +// nil document with a nil error means the guild has never configured it. +func (s *registeredScope) read(ctx context.Context, guildID string) (json.RawMessage, error) { + key := cacheKey(guildID, s.key) + if cached, ok := db.GetCacheEntry(key); ok { + return json.RawMessage(cached), nil + } + + raw, err := s.load(ctx, guildID) + if err != nil { + return nil, err + } + + db.SetCacheEntry(key, raw, cacheTTL()) + return raw, nil +} + +// readFresh reads past the cache, so a write is built on what is stored now +// rather than on a copy that may predate it. +func (s *registeredScope) readFresh(ctx context.Context, guildID string) (json.RawMessage, error) { + return s.load(ctx, guildID) +} + +// decode overlays a stored document onto the defaults, reporting false when the +// result is unusable and the caller should fall back to the defaults alone. +func decode[T any](s *registeredScope, raw json.RawMessage, guildID string) (T, bool) { + var value T + if err := s.resolve(raw, &value); err != nil { + slog.Warn("config unreadable, falling back to defaults", + "scope", s.key, "guild_id", guildID, slog.Any("error", err)) + var zero T + return zero, false + } + if err := s.validate(value); err != nil { + slog.Warn("config failed validation, falling back to defaults", + "scope", s.key, "guild_id", guildID, slog.Any("error", err)) + var zero T + return zero, false + } + return value, true +} + +// defaultValue returns a private copy of a scope's registered defaults. +func defaultValue[T any](s *registeredScope) T { + value, err := overlay[T](s.defaultsJSON, nil) + if err != nil { + var zero T + return zero + } + return value +} + +// overlay decodes a stored document onto a fresh copy of a scope's registered +// defaults. It returns the defaults alone, alongside an error, when the document +// cannot be decoded, and the zero value stands in for the settings type. +func overlay[T any](defaultsJSON []byte, raw json.RawMessage) (T, error) { + value := new(T) + if err := json.Unmarshal(defaultsJSON, value); err != nil { + return *value, err + } + if len(bytes.TrimSpace(raw)) > 0 { + if err := json.Unmarshal(raw, value); err != nil { + return *value, err + } + } + return *value, nil +} + +// scopeByType finds a scope by key and reports an error when it holds a +// different settings type than the caller asked for, which would otherwise +// decode into a zero value and look like an unconfigured guild. +func scopeByType[T any](key string) (*registeredScope, error) { + scope, err := scopeByKey(key) + if err != nil { + return nil, err + } + if want := reflect.TypeFor[T](); scope.typ != want { + return nil, fmt.Errorf("config scope %q holds %s, not %s", key, scope.typ, want) + } + return scope, nil +} + +func scopeByKey(key string) (*registeredScope, error) { + registryMu.RLock() + defer registryMu.RUnlock() + + scope, ok := registry[key] + if !ok { + return nil, fmt.Errorf("config scope %q is not registered", key) + } + return scope, nil +} + +// cacheKey namespaces a scope's cache entry. A Discord snowflake is digits and a +// scope key is a slash command name, so neither contains the separator and no +// two guild and scope pairs can collide. +func cacheKey(guildID, scopeKey string) string { + return cacheKeyPrefix + ":" + guildID + ":" + scopeKey +} + +// cacheTTL is how long a stored document may be reused before a read goes to +// the database again. Any write drops the entry straight away. +func cacheTTL() time.Duration { + return db.ParseCleanupIntervalEnv(cacheTTLEnv, cacheTTLMinutes) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..7e0092a --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,636 @@ +package config + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Neon-Genesis-Linux/pen-bot/internal/db" +) + +// testModule is the module name every scope registered by these tests claims. +const testModule = "test" + +// settings is the shape the framework tests register. It has one field whose +// default must survive a stored document that leaves it out, and one that must be +// overwritten by it. +type settings struct { + Enabled bool `json:"enabled"` + Limit int `json:"limit"` + Roles map[string]int `json:"roles"` +} + +// otherSettings exists so a scope can be read back as the wrong type. +type otherSettings struct { + Enabled bool `json:"enabled"` +} + +// sameSettings compares two settings values. The struct holds a map, so it +// cannot be compared with ==. +func sameSettings(got, want settings) bool { + return reflect.DeepEqual(got, want) +} + +func defaultSettings() settings { + return settings{ + Enabled: true, + Limit: 7, + Roles: map[string]int{"kept": 1}, + } +} + +func validateSettings(s settings) error { + if s.Limit < 0 { + return fmt.Errorf("limit must not be negative, got %d", s.Limit) + } + return nil +} + +// fakeStorage stands in for a real database so the framework's read, write and cache +// behaviour can be tested without one. +type fakeStorage struct { + mu sync.Mutex + documents map[string][]byte + reads int + writes int + resets int +} + +func newFakeStorage() *fakeStorage { + return &fakeStorage{documents: make(map[string][]byte)} +} + +func (f *fakeStorage) storage() storage { + return storage{ + load: func(_ context.Context, guildID string) (json.RawMessage, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.reads++ + return json.RawMessage(f.documents[guildID]), nil + }, + save: func(_ context.Context, guildID string, raw json.RawMessage) error { + f.mu.Lock() + defer f.mu.Unlock() + f.writes++ + f.documents[guildID] = append([]byte(nil), raw...) + return nil + }, + reset: func(_ context.Context, guildID string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.resets++ + _, stored := f.documents[guildID] + delete(f.documents, guildID) + return stored, nil + }, + } +} + +func (f *fakeStorage) put(guildID, document string) { + f.mu.Lock() + defer f.mu.Unlock() + f.documents[guildID] = []byte(document) +} + +func (f *fakeStorage) counts() (reads, writes, resets int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.reads, f.writes, f.resets +} + +// guildSeq keeps every test on its own guild. The cache the framework reads from +// is process wide and outlives a test, so reusing an ID would let one test's +// stored document answer another's. +var guildSeq atomic.Int64 + +func testGuildID() string { + return strconv.FormatInt(900000000000000000+guildSeq.Add(1), 10) +} + +// scopeSeq keeps every test on its own scope key, for the same reason. +var scopeSeq atomic.Int64 + +func testScopeKey() string { + return "test-" + strconv.FormatInt(scopeSeq.Add(1), 10) +} + +// registerTestScope registers a scope backed by the given storage and returns +// its key. +func registerTestScope(t *testing.T, store *fakeStorage) string { + t.Helper() + + key := testScopeKey() + err := register(ScopeDesc[settings]{ + Key: key, + Module: testModule, + Summary: "Settings for the framework tests.", + Defaults: defaultSettings(), + Validate: validateSettings, + }, store.storage()) + if err != nil { + t.Fatalf("registering scope %q failed: %v", key, err) + } + return key +} + +func TestGetOverlaysTheStoredDocumentOnTheDefaults(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + guildID := testGuildID() + store.put(guildID, `{"limit":9,"roles":{"given":2}}`) + + got, err := Get[settings](t.Context(), key, guildID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + // The default map entry survives: json decodes an object into the map it + // finds rather than replacing it. + want := settings{Enabled: true, Limit: 9, Roles: map[string]int{"given": 2, "kept": 1}} + if !sameSettings(got, want) { + t.Errorf("Get = %+v, want %+v", got, want) + } +} + +func TestGetKeepsADefaultKeyTheDocumentMergesInto(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + guildID := testGuildID() + store.put(guildID, `{"limit":9}`) + + got, err := Get[settings](t.Context(), key, guildID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if !got.Enabled { + t.Error("Enabled = false, want the registered default: the document leaves the key out") + } + if len(got.Roles) != 1 || got.Roles["kept"] != 1 { + t.Errorf("Roles = %v, want the registered default preserved", got.Roles) + } +} + +func TestGetWithoutAStoredDocumentReturnsTheDefaults(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + + got, err := Get[settings](t.Context(), key, testGuildID()) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if !sameSettings(got, defaultSettings()) { + t.Errorf("Get = %+v, want the registered defaults %+v", got, defaultSettings()) + } +} + +func TestGetFallsBackWhenTheDocumentCannotBeDecoded(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + guildID := testGuildID() + store.put(guildID, `{"limit":"not a number"}`) + + got, err := Get[settings](t.Context(), key, guildID) + if err != nil { + t.Fatalf("Get = %v, want a document too broken to decode to fall back to the defaults", err) + } + if !sameSettings(got, defaultSettings()) { + t.Errorf("Get = %+v, want the registered defaults %+v", got, defaultSettings()) + } +} + +func TestGetFallsBackWhenTheDocumentFailsValidation(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + guildID := testGuildID() + store.put(guildID, `{"limit":-1}`) + + got, err := Get[settings](t.Context(), key, guildID) + if err != nil { + t.Fatalf("Get = %v, want an invalid document to fall back to the defaults", err) + } + if !sameSettings(got, defaultSettings()) { + t.Errorf("Get = %+v, want the registered defaults %+v", got, defaultSettings()) + } +} + +func TestGetRejectsAnUnregisteredScope(t *testing.T) { + _, err := Get[settings](t.Context(), "no-such-scope", testGuildID()) + if err == nil { + t.Fatal("Get = nil error, want an error for an unregistered scope") + } +} + +func TestGetRejectsTheWrongSettingsType(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + + _, err := Get[otherSettings](t.Context(), key, testGuildID()) + if err == nil { + t.Fatal("Get = nil error, want an error when the scope holds another settings type") + } +} + +func TestRegisterRejectsADuplicateKey(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + + err := register(ScopeDesc[settings]{ + Key: key, + Module: testModule, + Summary: "A second registration under the same key.", + Defaults: defaultSettings(), + Validate: validateSettings, + }, store.storage()) + if err == nil { + t.Fatal("register = nil error, want an error for a key already taken") + } +} + +func TestRegisterRejectsAnEmptyKey(t *testing.T) { + store := newFakeStorage() + + err := register(ScopeDesc[settings]{ + Key: "", + Module: testModule, + Summary: "A registration with no key.", + Defaults: defaultSettings(), + Validate: validateSettings, + }, store.storage()) + if err == nil { + t.Fatal("register = nil error, want an error for an empty key") + } +} + +func TestRegisterRejectsIncompleteStorage(t *testing.T) { + err := register(ScopeDesc[settings]{ + Key: testScopeKey(), + Module: testModule, + Summary: "A registration with nowhere to store its settings.", + Defaults: defaultSettings(), + Validate: validateSettings, + }, storage{ + load: func(_ context.Context, _ string) (json.RawMessage, error) { return nil, nil }, + }) + if err == nil { + t.Fatal("register = nil error, want an error when save and reset are missing") + } +} + +func TestGetWithoutADatabaseReportsTheError(t *testing.T) { + // The scope loads through a database handle the bot does not have, which is + // what a bot with no database handle sees. Reads fail closed rather than + // serving defaults that may be stale. + unavailable := registerScopeLoading(errNoDatabase, t) + + got, err := Get[settings](t.Context(), unavailable, testGuildID()) + if err == nil { + t.Fatalf("Get = %v, want the error when the database is unavailable", got) + } + if !errors.Is(err, errNoDatabase) { + t.Fatalf("Get error = %v, want %v", err, errNoDatabase) + } +} + +func TestUpdateWithoutADatabaseReportsTheError(t *testing.T) { + unavailable := registerScopeLoading(errNoDatabase, t) + + err := Update(t.Context(), unavailable, testGuildID(), func(s *settings) error { + s.Limit = 1 + return nil + }) + if !errors.Is(err, errNoDatabase) { + t.Fatalf("Update = %v, want %v", err, errNoDatabase) + } +} + +func TestUpdateStoresTheResultAndDropsTheCache(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + guildID := testGuildID() + store.put(guildID, `{"limit":3}`) + + // Prime the cache, so that a stale entry would answer the read after the write. + if _, err := Get[settings](t.Context(), key, guildID); err != nil { + t.Fatalf("the priming Get failed: %v", err) + } + + err := Update(t.Context(), key, guildID, func(s *settings) error { + s.Limit = 11 + return nil + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + if _, ok := db.GetCacheEntry(cacheKey(guildID, key)); ok { + t.Error("the cache entry survived the write") + } + + got, err := Get[settings](t.Context(), key, guildID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if got.Limit != 11 { + t.Errorf("Limit = %d, want 11", got.Limit) + } +} + +func TestUpdateReadsPastTheCache(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + guildID := testGuildID() + store.put(guildID, `{"limit":3}`) + + if _, err := Get[settings](t.Context(), key, guildID); err != nil { + t.Fatalf("the priming Get failed: %v", err) + } + store.put(guildID, `{"limit":4,"roles":{"elsewhere":5}}`) + + err := Update(t.Context(), key, guildID, func(s *settings) error { + s.Enabled = false + return nil + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + got, err := Get[settings](t.Context(), key, guildID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if got.Limit != 4 { + t.Errorf("Limit = %d, want 4: a write must build on what is stored, not on the cached copy", got.Limit) + } + if got.Roles["elsewhere"] != 5 { + t.Errorf("Roles = %v, want the change made after the cached read to survive", got.Roles) + } +} + +func TestUpdateLeavesTheDocumentAloneWhenMutateRefuses(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + guildID := testGuildID() + refused := errors.New("refused") + + err := Update(t.Context(), key, guildID, func(s *settings) error { + s.Limit = 11 + return refused + }) + if !errors.Is(err, refused) { + t.Fatalf("Update = %v, want %v", err, refused) + } + if _, writes, _ := store.counts(); writes != 0 { + t.Errorf("the document was written %d times, want 0", writes) + } +} + +func TestUpdateRejectsAValueTheScopeForbids(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + guildID := testGuildID() + + err := Update(t.Context(), key, guildID, func(s *settings) error { + s.Limit = -1 + return nil + }) + if err == nil { + t.Fatal("Update = nil error, want the scope's validation to reject the value") + } + if _, writes, _ := store.counts(); writes != 0 { + t.Errorf("the document was written %d times, want 0", writes) + } +} + +func TestUpdateNeedsAMutateFunction(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + + if err := Update[settings](t.Context(), key, testGuildID(), nil); err == nil { + t.Fatal("Update = nil error, want an error for a missing mutate function") + } +} + +func TestUpdateStartsFromTheDefaultsWhenTheDocumentIsBroken(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + guildID := testGuildID() + store.put(guildID, `{"limit":"not a number"}`) + + err := Update(t.Context(), key, guildID, func(s *settings) error { + s.Limit = 4 + return nil + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + got, err := Get[settings](t.Context(), key, guildID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if !got.Enabled || got.Limit != 4 { + t.Errorf("Get = %+v, want the defaults the broken document was replaced with, plus the change", got) + } +} + +func TestDocumentShowsTheResolvedSettings(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + guildID := testGuildID() + store.put(guildID, `{"limit":9}`) + + document, err := Document(t.Context(), key, guildID) + if err != nil { + t.Fatalf("Document failed: %v", err) + } + + var got settings + if err := json.Unmarshal(document, &got); err != nil { + t.Fatalf("Document = %s, which is not a settings document: %v", document, err) + } + if !got.Enabled || got.Limit != 9 { + t.Errorf("Document = %s, want the stored limit with the default enabled", document) + } +} + +func TestResetDropsTheStoredDocument(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + guildID := testGuildID() + store.put(guildID, `{"limit":9}`) + + reset, err := Reset(t.Context(), key, guildID) + if err != nil { + t.Fatalf("Reset failed: %v", err) + } + if !reset { + t.Error("Reset = false, want true for a guild with a stored document") + } + + got, err := Get[settings](t.Context(), key, guildID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if !sameSettings(got, defaultSettings()) { + t.Errorf("Get = %+v, want the registered defaults %+v", got, defaultSettings()) + } +} + +func TestResetReportsNothingStored(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + + reset, err := Reset(t.Context(), key, testGuildID()) + if err != nil { + t.Fatalf("Reset failed: %v", err) + } + if reset { + t.Error("Reset = true, want false for a guild with no stored document") + } +} + +func TestResetRejectsAnUnregisteredScope(t *testing.T) { + if _, err := Reset(t.Context(), "no-such-scope", testGuildID()); err == nil { + t.Fatal("Reset = nil error, want an error for an unregistered scope") + } +} + +func TestAReadIsServedFromTheCacheUntilItIsWritten(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + guildID := testGuildID() + + if _, err := Get[settings](t.Context(), key, guildID); err != nil { + t.Fatalf("the first Get failed: %v", err) + } + if _, err := Get[settings](t.Context(), key, guildID); err != nil { + t.Fatalf("the second Get failed: %v", err) + } + + reads, _, _ := store.counts() + if reads != 1 { + t.Errorf("the scope was read %d times, want 1: the second read should have come from the cache", reads) + } +} + +func TestConcurrentReadsDoNotShareTheRegisteredDefaults(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + guildID := testGuildID() + store.put(guildID, `{"roles":{"given":2}}`) + + var wg sync.WaitGroup + for range 16 { + wg.Add(1) + go func() { + defer wg.Done() + got, err := Get[settings](t.Context(), key, guildID) + if err != nil { + t.Errorf("Get failed: %v", err) + return + } + if got.Roles["kept"] != 1 { + t.Errorf("Roles = %v, want the registered default alongside the stored key", got.Roles) + return + } + delete(got.Roles, "kept") + }() + } + wg.Wait() + + // A guild with no stored document reads the registered defaults, so its map + // still holding "kept" proves that no reader wrote into the shared default. + fresh, err := Get[settings](t.Context(), key, testGuildID()) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if fresh.Roles["kept"] != 1 { + t.Errorf("Roles = %v, want the registered default map intact after concurrent overlays", fresh.Roles) + } +} + +func TestCacheKeySeparatesGuildsAndScopes(t *testing.T) { + if cacheKey("1", "ab") == cacheKey("1a", "b") { + t.Errorf("cacheKey(1, ab) = %q collides with cacheKey(1a, b)", cacheKey("1", "ab")) + } +} + +func TestCacheTTLComesFromTheEnvironment(t *testing.T) { + t.Setenv(cacheTTLEnv, "90m") + + if got := cacheTTL(); got != 90*time.Minute { + t.Errorf("cacheTTL = %v, want 90m", got) + } +} + +func TestCacheTTLDefaultsWhenUnset(t *testing.T) { + t.Setenv(cacheTTLEnv, "") + + if got := cacheTTL(); got != time.Duration(cacheTTLMinutes)*time.Minute { + t.Errorf("cacheTTL = %v, want %v", got, time.Duration(cacheTTLMinutes)*time.Minute) + } +} + +func TestScopesAreOrderedAndDescribeThemselves(t *testing.T) { + store := newFakeStorage() + key := registerTestScope(t, store) + if _, ok := ScopeByKey(key); !ok { + t.Errorf("ScopeByKey(%q) = false, want the scope to be findable", key) + } + + missing, ok := ScopeByKey("no-such-scope") + if ok { + t.Error("ScopeByKey(no-such-scope) = true, want false") + } + if missing.Key != "no-such-scope" { + t.Errorf("ScopeByKey(no-such-scope).Key = %q, want the key that was asked for", missing.Key) + } + + scopes := Scopes() + if len(scopes) < 2 { + t.Fatalf("Scopes returned %d entries, want at least the two registered by these tests", len(scopes)) + } + for i := 1; i < len(scopes); i++ { + previous, current := scopes[i-1], scopes[i] + if current.Module < previous.Module || + (current.Module == previous.Module && current.Key < previous.Key) { + t.Errorf("Scopes is not ordered: %q/%q comes before %q/%q", + previous.Module, previous.Key, current.Module, current.Key) + } + } +} + +// registerScopeLoading registers a scope whose storage always reports the given +// error on a read, which is how the framework sees a bot with no database handle. +func registerScopeLoading(loadErr error, t *testing.T) string { + t.Helper() + + key := testScopeKey() + store := newFakeStorage() + writable := store.storage() + err := register(ScopeDesc[settings]{ + Key: key, + Module: testModule, + Summary: "Settings for the unavailable-database tests.", + Defaults: defaultSettings(), + Validate: validateSettings, + }, storage{ + load: func(_ context.Context, _ string) (json.RawMessage, error) { + return nil, loadErr + }, + save: writable.save, + reset: writable.reset, + }) + if err != nil { + t.Fatalf("registering scope %q failed: %v", key, err) + } + return key +} diff --git a/internal/db/settings.go b/internal/db/settings.go new file mode 100644 index 0000000..7fb13cb --- /dev/null +++ b/internal/db/settings.go @@ -0,0 +1,103 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// GetGuildDocument returns the guild's settings document, or nil when the guild +// has never been configured. +func GetGuildDocument(ctx context.Context, db *DB, guildID string) ([]byte, error) { + if guildID == "" { + return nil, fmt.Errorf("guildID required") + } + var raw []byte + err := db.QueryRowContext(ctx, ` + SELECT settings_json FROM guilds WHERE guild_id = $1 + `, guildID).Scan(&raw) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return raw, nil +} + +// SetGuildDocument replaces the guild's settings document outright, creating the +// guild's row when it does not exist yet. +// +// It is the coarse counterpart to SetGuildRoleTier: a caller that read the +// document before writing it discards every change made since. +func SetGuildDocument(ctx context.Context, db *DB, guildID, documentJSON string) error { + if guildID == "" { + return fmt.Errorf("guildID required") + } + now := time.Now().UTC() + _, err := db.ExecContext(ctx, ` + INSERT INTO guilds (guild_id, settings_json, created_at, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (guild_id) DO UPDATE SET + settings_json = EXCLUDED.settings_json, + updated_at = $4 + `, guildID, documentJSON, now, now) + return err +} + +// SetGuildRoleTier records the tier a guild treats a role as holding. It writes +// the single JSON path named after the role, so two guilds configuring different +// roles at the same time cannot discard each other's change, and a guild whose +// document lacks the key yet gets one created. +// +// The path is built from the role id in Go and bound as a parameter, so a role +// id taken from Discord can neither rewrite another role nor escape the roles +// object. json_set adds or replaces the leaf in one statement, whether the row +// is new or existing, so the write is atomic. +func SetGuildRoleTier(ctx context.Context, db *DB, guildID, roleID, tier string) error { + if guildID == "" { + return fmt.Errorf("guildID required") + } + if roleID == "" { + return fmt.Errorf("roleID required") + } + if tier == "" { + return fmt.Errorf("tier required") + } + now := time.Now().UTC() + _, err := db.ExecContext(ctx, ` + INSERT INTO guilds (guild_id, settings_json, created_at, updated_at) + VALUES ($1, json_set('{"roles":{}}', $4, $5), $2, $3) + ON CONFLICT (guild_id) DO UPDATE SET + settings_json = json_set(guilds.settings_json, $4, $5), + updated_at = $3 + `, guildID, now, now, "$.roles."+roleID, tier) + return err +} + +// RemoveGuildRole drops a role's tier from the guild's settings, reporting +// whether the guild held one for it. The path is guarded so that a role the guild +// never configured updates no row: an unguarded rewrite reports the row as +// affected even when the document comes out identical. +func RemoveGuildRole(ctx context.Context, db *DB, guildID, roleID string) (bool, error) { + if guildID == "" { + return false, fmt.Errorf("guildID required") + } + if roleID == "" { + return false, fmt.Errorf("roleID required") + } + now := time.Now().UTC() + result, err := db.ExecContext(ctx, ` + UPDATE guilds SET settings_json = json_remove(settings_json, $2), updated_at = $3 + WHERE guild_id = $1 AND json_extract(settings_json, $2) IS NOT NULL + `, guildID, "$.roles."+roleID, now) + if err != nil { + return false, err + } + affected, err := result.RowsAffected() + if err != nil { + return false, err + } + return affected > 0, nil +} diff --git a/internal/db/settings_test.go b/internal/db/settings_test.go new file mode 100644 index 0000000..6f38e34 --- /dev/null +++ b/internal/db/settings_test.go @@ -0,0 +1,98 @@ +package db_test + +import ( + "context" + "testing" + + "github.com/Neon-Genesis-Linux/pen-bot/internal/db" +) + +// roleNineAdmin is the guild document with a single admin role, compared often +// enough that goconst wants it pulled out of the assertions. +const roleNineAdmin = `{"roles":{"9":"admin"}}` + +// These exercise the settings SQL against the real SQLite driver, so the +// SQLite JSON and date functions these queries rely on are pinned here. They +// are in package db_test, which is the only package that has tests and needs +// no server. + +func TestSetGuildDocumentRoundTripsAndUpserts(t *testing.T) { + ctx := context.Background() + database := testDB(t) + const guild = "g1" + + if raw, err := db.GetGuildDocument(ctx, database, guild); err != nil || raw != nil { + t.Fatalf("missing guild document = %q, %v: want nil", raw, err) + } + + if err := db.SetGuildDocument(ctx, database, guild, `{"roles":{}}`); err != nil { + t.Fatalf("set: %v", err) + } + raw, err := db.GetGuildDocument(ctx, database, guild) + if err != nil || string(raw) != `{"roles":{}}` { + t.Fatalf("round trip = %q, %v: want the stored document", raw, err) + } + + if err := db.SetGuildDocument(ctx, database, guild, roleNineAdmin); err != nil { + t.Fatalf("upsert set: %v", err) + } + raw, err = db.GetGuildDocument(ctx, database, guild) + if err != nil || string(raw) != roleNineAdmin { + t.Fatalf("after upsert = %q, %v: want the replaced document", raw, err) + } +} + +func TestSetGuildRoleTierCreatesThenAdds(t *testing.T) { + ctx := context.Background() + database := testDB(t) + const guild = "g1" + + if err := db.SetGuildRoleTier(ctx, database, guild, "9", "admin"); err != nil { + t.Fatalf("set first role: %v", err) + } + raw, err := db.GetGuildDocument(ctx, database, guild) + if err != nil || string(raw) != roleNineAdmin { + t.Fatalf("after first role = %q, %v", raw, err) + } + + // A second, different role must not overwrite the first: the path is written + // individually rather than by replacing the whole document. + if err := db.SetGuildRoleTier(ctx, database, guild, "10", "moderator"); err != nil { + t.Fatalf("set second role: %v", err) + } + raw, err = db.GetGuildDocument(ctx, database, guild) + if err != nil || string(raw) != `{"roles":{"9":"admin","10":"moderator"}}` { + t.Fatalf("after second role = %q, %v: want both roles", raw, err) + } +} + +func TestRemoveGuildRoleDropsTheRoleAndReportsIt(t *testing.T) { + ctx := context.Background() + database := testDB(t) + const guild = "g1" + + if err := db.SetGuildRoleTier(ctx, database, guild, "9", "admin"); err != nil { + t.Fatalf("set role: %v", err) + } + if err := db.SetGuildRoleTier(ctx, database, guild, "10", "moderator"); err != nil { + t.Fatalf("set role: %v", err) + } + + removed, err := db.RemoveGuildRole(ctx, database, guild, "10") + if err != nil || !removed { + t.Fatalf("remove = %v, %v: want true", removed, err) + } + raw, err := db.GetGuildDocument(ctx, database, guild) + if err != nil || string(raw) != roleNineAdmin { + t.Fatalf("after remove = %q, %v: want only the remaining role", raw, err) + } + + // Removing a role the guild never configured touches no row. + if removed, err := db.RemoveGuildRole(ctx, database, guild, "11"); err != nil || removed { + t.Fatalf("remove unknown = %v, %v: want false", removed, err) + } + raw, err = db.GetGuildDocument(ctx, database, guild) + if err != nil || string(raw) != roleNineAdmin { + t.Fatalf("document changed on an unguarded path: %q, %v", raw, err) + } +}