From 0f6ff76165a029a49351d199610a8b45688022a4 Mon Sep 17 00:00:00 2001 From: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:00:16 -0700 Subject: [PATCH] Add Replace and Update funtions to model --- backend/internal/appconfig/model.go | 93 +++++++++++ backend/internal/appconfig/model_test.go | 201 +++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 backend/internal/appconfig/model_test.go diff --git a/backend/internal/appconfig/model.go b/backend/internal/appconfig/model.go index 4040eee9..b2f89bc5 100644 --- a/backend/internal/appconfig/model.go +++ b/backend/internal/appconfig/model.go @@ -1,12 +1,15 @@ package appconfig import ( + "errors" "fmt" "reflect" "strconv" "strings" "github.com/italypaleale/go-kit/utils" + + "github.com/pocket-id/pocket-id/backend/internal/dto" ) type AppConfigModel struct { @@ -153,3 +156,93 @@ func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) { return dest, nil } + +// Replace updates every configuration property with the values from the input DTO +// An empty string value resets the corresponding property to its default value +func (m *AppConfigModel) Replace(input dto.AppConfigUpdateDto) error { + // Collect the values from the input DTO into a map, keyed by the "json" tag + inRv := reflect.ValueOf(input) + inRt := inRv.Type() + values := make(map[string]string, inRt.NumField()) + for i := range inRt.NumField() { + // Get the value of the json tag, taking only what's before the comma + key, _, _ := strings.Cut(inRt.Field(i).Tag.Get("json"), ",") + values[key] = inRv.Field(i).String() + } + + // Iterate through all the properties, setting each one from the input + // Properties that are missing from the input or have an empty value are reset to their default + defaults := reflect.ValueOf(getDefaultConfig()).Elem() + rv := reflect.ValueOf(m).Elem() + rt := rv.Type() + for i := range rt.NumField() { + key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",") + + value, ok := values[key] + if !ok || value == "" { + value = defaults.Field(i).String() + } + + rv.Field(i).SetString(value) + } + + return nil +} + +// Update sets configuration properties from the provided key-value pairs +// Keys correspond to the "json" tags on the model +// An empty string value resets the property to its default value +func (m *AppConfigModel) Update(keysAndValues ...string) error { + // Count of keysAndValues must be even + if len(keysAndValues)%2 != 0 { + return errors.New("invalid number of arguments received") + } + + rv := reflect.ValueOf(m).Elem() + rt := rv.Type() + defaults := reflect.ValueOf(getDefaultConfig()).Elem() + + // Iterate through the key-value pairs + // (Note the += 2, as we are iterating through key-value pairs) + for i := 1; i < len(keysAndValues); i += 2 { + key := keysAndValues[i-1] + value := keysAndValues[i] + + // Find the field in the struct whose "json" tag matches + fieldIdx := -1 + for j := range rt.NumField() { + // Separate the key (before the comma) from any optional attributes after + tagValue, _, _ := strings.Cut(rt.Field(j).Tag.Get("json"), ",") + if tagValue == key { + fieldIdx = j + break + } + } + if fieldIdx < 0 { + return AppConfigKeyNotFoundError{field: key} + } + + // An empty string means we use the default value for the property + if value == "" { + value = defaults.Field(fieldIdx).String() + } + + rv.Field(fieldIdx).SetString(value) + } + + return nil +} + +type AppConfigKeyNotFoundError struct { + field string +} + +func (e AppConfigKeyNotFoundError) Error() string { + return "cannot find config key '" + e.field + "'" +} + +func (e AppConfigKeyNotFoundError) Is(target error) bool { + // Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError + _, ok := errors.AsType[*AppConfigKeyNotFoundError](target) + return ok +} diff --git a/backend/internal/appconfig/model_test.go b/backend/internal/appconfig/model_test.go new file mode 100644 index 00000000..123de9b7 --- /dev/null +++ b/backend/internal/appconfig/model_test.go @@ -0,0 +1,201 @@ +package appconfig + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/dto" +) + +// dtoWithMarkerValues returns a DTO where every field is set to a unique, non-empty marker derived from its "json" key, so we can assert each value lands in the right place. +func dtoWithMarkerValues() dto.AppConfigUpdateDto { + var input dto.AppConfigUpdateDto + rv := reflect.ValueOf(&input).Elem() + rt := rv.Type() + for i := range rt.NumField() { + key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",") + rv.Field(i).SetString("marker-" + key) + } + return input +} + +func TestAppConfigModel_Replace(t *testing.T) { + t.Run("populates every property from the DTO", func(t *testing.T) { + input := dtoWithMarkerValues() + + var m AppConfigModel + err := m.Replace(input) + require.NoError(t, err) + + // Each model property must hold the marker built from its own "json" key. + // This also asserts that the model and the DTO share the same set of keys. + rv := reflect.ValueOf(&m).Elem() + rt := rv.Type() + for i := range rt.NumField() { + key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",") + assert.Equalf(t, "marker-"+key, rv.Field(i).String(), "property %s (key %q)", rt.Field(i).Name, key) + } + }) + + t.Run("empty values fall back to their default", func(t *testing.T) { + defaults := getDefaultConfig() + + // Start from all-markers, then blank out a few properties whose default is non-empty + input := dtoWithMarkerValues() + input.AppName = "" + input.SessionDuration = "" + input.SmtpTls = "" + input.LdapUserSearchFilter = "" + + var m AppConfigModel + err := m.Replace(input) + require.NoError(t, err) + + // Blanked properties are reset to their default + assert.Equal(t, defaults.AppName, m.AppName) + assert.Equal(t, defaults.SessionDuration, m.SessionDuration) + assert.Equal(t, defaults.SmtpTls, m.SmtpTls) + assert.Equal(t, defaults.LdapUserSearchFilter, m.LdapUserSearchFilter) + + // A property that was provided keeps the provided value + assert.Equal(t, "marker-homePageUrl", m.HomePageURL) + }) + + t.Run("an empty DTO resets every property to its default", func(t *testing.T) { + // Pre-populate with junk to prove Replace overwrites existing state + m := AppConfigModel{ + AppName: "Custom Name", + LdapEnabled: "true", + SmtpHost: "smtp.example.com", + } + + err := m.Replace(dto.AppConfigUpdateDto{}) + require.NoError(t, err) + + assert.Equal(t, *getDefaultConfig(), m) + }) + + t.Run("provided values overwrite existing non-default values", func(t *testing.T) { + m := getDefaultConfig() + m.AppName = "Old Name" + m.LdapEnabled = "true" + + input := dto.AppConfigUpdateDto{} + input.AppName = "New Name" + + err := m.Replace(input) + require.NoError(t, err) + + // Explicitly provided value wins + assert.Equal(t, "New Name", m.AppName) + // Everything else in the DTO was empty, so it is reset to the default + assert.Equal(t, getDefaultConfig().LdapEnabled, m.LdapEnabled) + }) + + t.Run("stores raw string values without type coercion", func(t *testing.T) { + input := dto.AppConfigUpdateDto{} + input.SessionDuration = "120" // int-tagged property + input.LdapEnabled = "true" // bool-tagged property + + var m AppConfigModel + err := m.Replace(input) + require.NoError(t, err) + + assert.Equal(t, "120", m.SessionDuration) + assert.Equal(t, "true", m.LdapEnabled) + }) +} + +func TestAppConfigModel_Update(t *testing.T) { + t.Run("updates a single property", func(t *testing.T) { + m := getDefaultConfig() + + err := m.Update("appName", "My App") + require.NoError(t, err) + + assert.Equal(t, "My App", m.AppName) + }) + + t.Run("updates multiple properties and leaves others untouched", func(t *testing.T) { + m := getDefaultConfig() + + err := m.Update("appName", "My App", "homePageUrl", "/home", "ldapEnabled", "true") + require.NoError(t, err) + + assert.Equal(t, "My App", m.AppName) + assert.Equal(t, "/home", m.HomePageURL) + assert.Equal(t, "true", m.LdapEnabled) + // A property that was not part of the update keeps its previous value + assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration) + }) + + t.Run("an empty value resets the property to its default", func(t *testing.T) { + m := getDefaultConfig() + m.SmtpTls = "tls" // default is "none" + m.SessionDuration = "120" // default is "60" + + err := m.Update("smtpTls", "", "sessionDuration", "") + require.NoError(t, err) + + assert.Equal(t, getDefaultConfig().SmtpTls, m.SmtpTls) + assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration) + }) + + t.Run("stores raw string values without type coercion", func(t *testing.T) { + m := getDefaultConfig() + + err := m.Update("sessionDuration", "120", "disableAnimations", "true") + require.NoError(t, err) + + assert.Equal(t, "120", m.SessionDuration) + assert.Equal(t, "true", m.DisableAnimations) + }) + + t.Run("later value wins for a repeated key", func(t *testing.T) { + m := getDefaultConfig() + + err := m.Update("appName", "First", "appName", "Second") + require.NoError(t, err) + + assert.Equal(t, "Second", m.AppName) + }) + + t.Run("no arguments is a no-op", func(t *testing.T) { + m := getDefaultConfig() + before := *m + + err := m.Update() + require.NoError(t, err) + + assert.Equal(t, before, *m) + }) + + t.Run("an odd number of arguments returns an error", func(t *testing.T) { + m := getDefaultConfig() + before := *m + + err := m.Update("appName") + require.Error(t, err) + assert.EqualError(t, err, "invalid number of arguments received") + + // The config must not have been modified + assert.Equal(t, before, *m) + }) + + t.Run("an unknown key returns AppConfigKeyNotFoundError", func(t *testing.T) { + m := getDefaultConfig() + + err := m.Update("thisKeyDoesNotExist", "value") + require.Error(t, err) + assert.EqualError(t, err, "cannot find config key 'thisKeyDoesNotExist'") + + notFound, ok := errors.AsType[AppConfigKeyNotFoundError](err) + require.True(t, ok) + assert.Equal(t, "thisKeyDoesNotExist", notFound.field) + }) +}