refactor(config): mandatory app-config rows seeded in DB; rename to session.setup_timeout

All registry keys must exist as rows in the config table: main seeds
missing keys with their defaults at startup, LoadApp fails fast on a
missing key, and the code-side fallback const/helper for the onboarding
setup timeout is gone -- the value rides in SessionConfig.SetupTimeout.
The key is renamed session.idle_timeout -> session.setup_timeout, and
the /config page's 'overridden' now means 'differs from the default'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:17:24 +02:00
parent 8c9285f33c
commit 0e6cf00dba
16 changed files with 127 additions and 120 deletions

View File

@@ -19,13 +19,13 @@ type AppKey struct {
// KeySessionDuration is the session cookie lifetime, in integer hours.
const (
KeySessionDuration = "session.duration"
// KeySessionIdleTimeout bounds an *onboarding Garmin session* (the
// ephemeral, pre-account login attempt), not the login cookie --
// KeySessionSetupTimeout bounds an *onboarding Garmin setup session*
// (the ephemeral, pre-account login attempt), not the login cookie --
// session.duration is how long a signed-in user stays signed in
// (hours); session.idle_timeout is how long an unfinished setup
// (hours); session.setup_timeout is how long an unfinished setup
// attempt survives untouched before its subprocess is torn down
// (minutes).
KeySessionIdleTimeout = "session.idle_timeout"
KeySessionSetupTimeout = "session.setup_timeout"
)
var appRegistry = []AppKey{
@@ -36,9 +36,9 @@ var appRegistry = []AppKey{
Validate: validatePositiveInt,
},
{
Key: KeySessionIdleTimeout,
Key: KeySessionSetupTimeout,
Default: "15",
Description: "Idle timeout in minutes for an unfinished onboarding Garmin session",
Description: "Idle timeout in minutes for an unfinished onboarding Garmin setup session",
Validate: validatePositiveInt,
},
}
@@ -69,32 +69,35 @@ func ValidateAppValue(key, value string) error {
return fmt.Errorf("unknown configuration key %q", key)
}
// AppConfig is the typed result of merging DB overrides over registry
// defaults.
// AppConfig is the typed application configuration, built from the DB's
// config rows.
type AppConfig struct {
SessionDuration time.Duration
SetupSessionIdleTimeout time.Duration
SessionDuration time.Duration
SetupTimeout time.Duration
}
// LoadApp merges overrides (store.ConfigValues' map) over defaults. Pure
// -- it takes the raw map instead of a *store.DB so this package needs no
// store dependency and the merge logic tests without a database. A bad
// stored value fails fast, same posture as LoadEnv() for env vars.
func LoadApp(overrides map[string]string) (AppConfig, error) {
merged := map[string]string{}
for _, k := range appRegistry {
merged[k.Key] = k.Default
}
for key, value := range overrides {
// LoadApp builds the typed AppConfig from the config table's rows. Every
// registry key is mandatory: main seeds missing keys with their defaults
// at startup (see cmd/geniusrund), so a missing key here means that
// seeding didn't run -- fail fast, same posture as LoadEnv() for env
// vars, and likewise for an unknown or invalid stored value. Pure -- it
// takes the raw map instead of a *store.DB so this package needs no store
// dependency and the logic tests without a database.
func LoadApp(values map[string]string) (AppConfig, error) {
for key, value := range values {
if err := ValidateAppValue(key, value); err != nil {
return AppConfig{}, fmt.Errorf("app config: %w", err)
}
merged[key] = value
}
hours, _ := strconv.Atoi(merged[KeySessionDuration])
idleMinutes, _ := strconv.Atoi(merged[KeySessionIdleTimeout])
for _, k := range appRegistry {
if _, ok := values[k.Key]; !ok {
return AppConfig{}, fmt.Errorf("app config: missing key %q (defaults are seeded into the DB at startup)", k.Key)
}
}
hours, _ := strconv.Atoi(values[KeySessionDuration])
setupMinutes, _ := strconv.Atoi(values[KeySessionSetupTimeout])
return AppConfig{
SessionDuration: time.Duration(hours) * time.Hour,
SetupSessionIdleTimeout: time.Duration(idleMinutes) * time.Minute,
SessionDuration: time.Duration(hours) * time.Hour,
SetupTimeout: time.Duration(setupMinutes) * time.Minute,
}, nil
}

View File

@@ -8,22 +8,25 @@ import (
func TestLoadApp(t *testing.T) {
tests := []struct {
name string
overrides map[string]string
want time.Duration
wantIdle time.Duration
wantErr string
name string
values map[string]string
want time.Duration
wantIdle time.Duration
wantErr string
}{
{name: "defaults when no overrides", overrides: nil, want: 720 * time.Hour, wantIdle: 15 * time.Minute},
{name: "override applied", overrides: map[string]string{"session.duration": "168"}, want: 168 * time.Hour, wantIdle: 15 * time.Minute},
{name: "idle timeout override applied", overrides: map[string]string{"session.idle_timeout": "30"}, want: 720 * time.Hour, wantIdle: 30 * time.Minute},
{name: "invalid stored value", overrides: map[string]string{"session.duration": "zero"}, wantErr: "session.duration"},
{name: "invalid idle timeout", overrides: map[string]string{"session.idle_timeout": "0"}, wantErr: "session.idle_timeout"},
{name: "unknown stored key", overrides: map[string]string{"bogus.key": "1"}, wantErr: "unknown configuration key"},
// Every registry key is mandatory: main seeds the DB with defaults
// at startup, so LoadApp always receives a complete map.
{name: "seeded defaults", values: map[string]string{"session.duration": "720", "session.setup_timeout": "15"}, want: 720 * time.Hour, wantIdle: 15 * time.Minute},
{name: "custom values", values: map[string]string{"session.duration": "168", "session.setup_timeout": "30"}, want: 168 * time.Hour, wantIdle: 30 * time.Minute},
{name: "missing key fails fast", values: map[string]string{"session.duration": "720"}, wantErr: "missing key"},
{name: "nil map fails fast", values: nil, wantErr: "missing key"},
{name: "invalid stored value", values: map[string]string{"session.duration": "zero", "session.setup_timeout": "15"}, wantErr: "session.duration"},
{name: "invalid setup timeout", values: map[string]string{"session.duration": "720", "session.setup_timeout": "0"}, wantErr: "session.setup_timeout"},
{name: "unknown stored key", values: map[string]string{"session.duration": "720", "session.setup_timeout": "15", "bogus.key": "1"}, wantErr: "unknown configuration key"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := LoadApp(tt.overrides)
got, err := LoadApp(tt.values)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
@@ -36,8 +39,8 @@ func TestLoadApp(t *testing.T) {
if got.SessionDuration != tt.want {
t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want)
}
if got.SetupSessionIdleTimeout != tt.wantIdle {
t.Fatalf("SetupSessionIdleTimeout = %v, want %v", got.SetupSessionIdleTimeout, tt.wantIdle)
if got.SetupTimeout != tt.wantIdle {
t.Fatalf("SetupSessionIdleTimeout = %v, want %v", got.SetupTimeout, tt.wantIdle)
}
})
}