diff --git a/internal/app/app.go b/internal/app/app.go index a56ad99..7646a68 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -79,6 +79,13 @@ type App struct { syncRequested chan struct{} } +// Settings this application has retired. They are read and discarded: a +// config.toml written by an older binary must never stop the new one from +// starting, and the next SaveSettings rewrites the file without them. An +// unrecognised key is still refused, so a typo cannot silently lose a +// preference. +var retiredSettings = map[string]bool{"include_amount": true} + func Open(dir string) (*App, error) { j, err := journal.Open(dir) if err != nil { @@ -115,7 +122,9 @@ func Open(dir string) (*App, error) { case "classify_on_import": a.settings.ClassifyOnImport, err = strconv.ParseBool(v) default: - err = fmt.Errorf("unknown setting %q", k) + if !retiredSettings[k] { + err = fmt.Errorf("unknown setting %q", k) + } } if err != nil { return fail(fmt.Errorf("config.toml:%d: %w", n+1, err)) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 0177e2e..56ab438 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -7,6 +7,8 @@ import ( "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" "reflect" "strings" "sync/atomic" @@ -57,6 +59,43 @@ func seed(t *testing.T, a *App, s State) State { } return result.State } + +// A released binary wrote include_amount into config.toml. Refusing it on +// startup made every upgraded deployment crash-loop against its own settings +// file, so a retired key must load and then disappear on the next save. +func TestRetiredSettingLoadsAndIsRewrittenAwayButTyposStillFail(t *testing.T) { + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("ENABLEBANKING_APP_ID", "") + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + if err := os.WriteFile(path, []byte("classification_model = \"old/model\"\ninclude_amount = true\nclassify_on_import = false\n"), 0600); err != nil { + t.Fatal(err) + } + a, err := Open(dir) + if err != nil { + t.Fatalf("retired setting must not stop startup: %v", err) + } + defer a.Close() + if a.settings.Model != "old/model" || a.settings.ClassifyOnImport { + t.Fatalf("surrounding settings lost: %#v", a.settings) + } + if _, err := a.SaveSettings(context.Background(), Settings{Model: "new/model", ClassifyOnImport: true}); err != nil { + t.Fatal(err) + } + written, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(written), "include_amount") { + t.Fatalf("retired setting survived a save: %s", written) + } + if err := os.WriteFile(path, []byte("classify_on_imports = true\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := Open(dir); err == nil { + t.Fatal("a misspelled setting must still be refused") + } +} func TestFailedClassificationStillImportsAndRetryIsIdempotent(t *testing.T) { a, s := testApp(t) mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) }))