Add a typed, cached registry over the system_settings table so that values which used to be hardcoded Go literals can be changed at runtime. Schema (database/schema/schema.sql): - Extend system_settings with setting_type, min_value, max_value, requires_restart, and category columns (all ADD COLUMN IF NOT EXISTS, nullable for backward compat with the original three rows). - Seed rows for every tunable: session duration, password rules, login lockout, auth/device rate limits, OPDS page size, tombstone TTL, conversion cache TTL, sync queue interval/batch, and worker pool size/cap. Seed values equal the previous hardcoded literals, so behavior is unchanged on upgrade. ON CONFLICT DO NOTHING preserves any admin-modified values. Queries (queries.sql): - Add UpsertSystemSetting (RETURNING *) so new keys without a seed row can still be written through the API. - Add GetSystemSettingFull + GetAllSystemSettingsFull returning the full typed row. - Refactor CleanupExpiredRefreshTokens to take the retention window as a parameter (make_interval(secs => $1)) instead of the INTERVAL '7 days' literal, so it can follow a configurable session duration. Registry (internal/database/settings_registry.go): - SettingsRegistry holds an in-memory cache of all known settings, populated by Load at startup and refreshed by Reload on writes. - Typed domain getters (SessionDuration, PasswordRules, DeviceRateLimits, TombstoneTTL, OpdsPageSize, ConversionCacheTTL, SyncQueueConfig, WorkerPoolConfig, LoginLockout, AuthRateLimit, ...) with compiled-in fallback defaults and min/max clamping, so a corrupt or missing row can never break the app. - SettingDefaults is the single source of truth for keys, types, bounds, and human descriptions; All() exposes metadata + current values for the admin UI/API. The registry lives in the database package (rather than its own internal/settings package) because a quirk in this custom go1.26.5 toolchain prevented the large handlers package from importing any newly-created package; every consumer already imports database. Tests: settings_registry_test.go covers default validity per type, int clamping at both bounds, garbage-value fallback, and unknown-key lookup.
98 lines
3.0 KiB
Go
98 lines
3.0 KiB
Go
package database
|
|
|
|
import (
|
|
"strconv"
|
|
"testing"
|
|
)
|
|
|
|
// TestSettingDefaults ensures every seeded setting has a compiled default with
|
|
// a valid value for its declared type. This guards against typos that would
|
|
// silently fall back at runtime.
|
|
func TestSettingDefaults(t *testing.T) {
|
|
if len(SettingDefaults) == 0 {
|
|
t.Fatal("SettingDefaults is empty")
|
|
}
|
|
for _, d := range SettingDefaults {
|
|
if d.Key == "" {
|
|
t.Errorf("default has empty key: %+v", d)
|
|
continue
|
|
}
|
|
switch d.Type {
|
|
case SettingTypeInt:
|
|
if _, err := strconv.Atoi(d.Value); err != nil {
|
|
t.Errorf("int setting %s default %q is not an int: %v", d.Key, d.Value, err)
|
|
}
|
|
if d.Min != "" {
|
|
if _, err := strconv.Atoi(d.Min); err != nil {
|
|
t.Errorf("int setting %s min %q is not an int", d.Key, d.Min)
|
|
}
|
|
}
|
|
if d.Max != "" {
|
|
if _, err := strconv.Atoi(d.Max); err != nil {
|
|
t.Errorf("int setting %s max %q is not an int", d.Key, d.Max)
|
|
}
|
|
}
|
|
case SettingTypeBool:
|
|
if _, err := strconv.ParseBool(d.Value); err != nil {
|
|
t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value)
|
|
}
|
|
case SettingTypeString:
|
|
if d.Value == "" {
|
|
t.Errorf("string setting %s has empty default", d.Key)
|
|
}
|
|
default:
|
|
t.Errorf("setting %s has unknown type %q", d.Key, d.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSettingsRegistryGetIntClamping verifies that out-of-range DB values are
|
|
// clamped to the declared min/max, and that garbage falls back to the default.
|
|
func TestSettingsRegistryGetIntClamping(t *testing.T) {
|
|
r := &SettingsRegistry{values: map[string]string{}, q: nil}
|
|
|
|
// Seed with an over-max value; expect clamping to the max (3600).
|
|
r.values["scan_poll_interval_seconds"] = "999999"
|
|
if got := r.ScanPollInterval(); got.Seconds() != 3600 {
|
|
t.Errorf("expected clamp to 3600, got %v", got)
|
|
}
|
|
|
|
// Seed with an under-min value; expect clamp to min (1).
|
|
r.values["scan_poll_interval_seconds"] = "0"
|
|
if got := r.ScanPollInterval(); got.Seconds() != 1 {
|
|
t.Errorf("expected clamp to 1, got %v", got)
|
|
}
|
|
|
|
// Seed with garbage; expect fallback to default (60).
|
|
r.values["scan_poll_interval_seconds"] = "not-a-number"
|
|
if got := r.ScanPollInterval(); got.Seconds() != 60 {
|
|
t.Errorf("expected fallback default 60, got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestSettingsRegistryGetBoolFallback verifies bool parsing and fallback.
|
|
func TestSettingsRegistryGetBoolFallback(t *testing.T) {
|
|
r := &SettingsRegistry{values: map[string]string{}, q: nil}
|
|
|
|
r.values["auto_scan_enabled"] = "true"
|
|
if !r.AutoScanEnabled() {
|
|
t.Error("expected true")
|
|
}
|
|
|
|
r.values["auto_scan_enabled"] = "garbage"
|
|
// garbage falls back to default ("true")
|
|
if !r.AutoScanEnabled() {
|
|
t.Error("expected fallback to default true")
|
|
}
|
|
}
|
|
|
|
// TestLookupDefaultUnknownKey verifies unknown keys return ok=false.
|
|
func TestLookupDefaultUnknownKey(t *testing.T) {
|
|
if _, ok := LookupDefault("does_not_exist"); ok {
|
|
t.Error("expected ok=false for unknown key")
|
|
}
|
|
if _, ok := LookupDefault("session_duration_seconds"); !ok {
|
|
t.Error("expected ok=true for known key")
|
|
}
|
|
}
|