Files
finance-duck/internal/app/app_test.go
T
Lars Nolden da817078f4 Let an upgraded binary start against the settings it wrote before
The deployed service crash-looped 83 times on "config.toml:3: unknown setting
\"include_amount\"". The classification redesign retired that preference from
both the reader and the writer, but /var/lib/finance-duck/config.toml was
written by the previous binary and still names it, and Open refuses any key its
switch does not recognise. So the new binary would not start against its own
settings file: nixos-rebuild switched successfully, systemd restarted the unit
until it gave up, and the updater's health check failed - a deployment error
whose cause was neither the build nor the code that was deployed.

Retired settings are now read and discarded, and the next SaveSettings rewrites
the file without them. An unrecognised key is still refused, because a
misspelled preference that loads silently is a preference the user believes is
in force. Every future removal adds its key to the same list rather than
stranding the deployments that already hold it.

Verified by running the built binary against a config.toml carrying exactly the
line the host has: it starts and /api/health answers 200, where the previous
binary exited 1. The regression test fails with "retired setting must not stop
startup" before the change, and it still requires classify_on_imports to be
rejected.
2026-09-11 23:50:38 +02:00

374 lines
13 KiB
Go

package app
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"finance-duck/internal/analytics"
"finance-duck/internal/classification"
"finance-duck/internal/domain"
)
func testApp(t *testing.T) (*App, State) {
t.Helper()
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("ENABLEBANKING_APP_ID", "")
t.Setenv("ENABLEBANKING_KEY_FILE", "")
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
a, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.Close() })
s, err := a.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
s, err = a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error {
d.Accounts = append(d.Accounts, domain.Account{ID: "n26", DisplayName: "N26", Currency: "EUR", Active: true})
d.Categories = append(d.Categories, domain.Category{ID: "groceries", Name: "Groceries", ParentID: "cat_expenses", Kind: "expense"})
d.Tags = append(d.Tags, domain.Tag{ID: "home", Name: "home"})
return nil
})
if err != nil {
t.Fatal(err)
}
return a, s
}
func sampleFacts(description, date string, amount domain.Money) domain.Facts {
return domain.Facts{Source: "test", AccountID: "n26", BookingDate: date, Amount: amount, Currency: "EUR", RawDescription: description, ExternalID: hex.EncodeToString([]byte(description))}
}
func seed(t *testing.T, a *App, s State) State {
t.Helper()
a.mu.Lock()
result, err := a.importFacts(context.Background(), s, []domain.Facts{sampleFacts("REWE", "2026-09-08", "-42.80"), sampleFacts("EDEKA", "2026-09-09", "-19.30")}, nil)
a.mu.Unlock()
if err != nil {
t.Fatal(err)
}
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) }))
defer mock.Close()
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: mock.URL}
s = seed(t, a, s)
if len(s.Data.Transactions) != 2 {
t.Fatalf("lost imported transactions: %d", len(s.Data.Transactions))
}
for _, tx := range s.Data.Transactions {
if tx.Enrichment.CategoryID != domain.ExpenseFallback || tx.Enrichment.Classification.Error == "" {
t.Fatalf("missing fallback error: %+v", tx.Enrichment)
}
}
before := domain.Clone(s.Data)
a.mu.Lock()
again, err := a.importFacts(context.Background(), s, []domain.Facts{sampleFacts("REWE", "2026-09-08", "-42.80"), sampleFacts("EDEKA", "2026-09-09", "-19.30")}, nil)
a.mu.Unlock()
if err != nil {
t.Fatal(err)
}
if again.Imported != 0 || !reflect.DeepEqual(before, again.State.Data) {
t.Fatal("retry changed the canonical financial dataset")
}
dash, err := a.Dashboard(context.Background(), analytics.Filter{})
if err != nil {
t.Fatal(err)
}
if len(dash.Totals) != 1 || dash.Totals[0].Expenses != "62.1000" {
t.Fatalf("import not visible in analytics: %+v", dash.Totals)
}
}
func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
before := domain.Clone(s.Data)
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Retry-After", "300")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer provider.Close()
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, model := range []string{"test/model", "test/another-model"} {
preview, err := a.Preview(ctx, PreviewRequest{
Revision: s.Revision, From: "2026-09-01", To: "2026-09-30",
Model: model, Fields: Fields{Category: true},
})
if err != nil {
t.Fatal(err)
}
if preview.Analysed != 2 || len(preview.Errors) != 2 || len(preview.Changes) != 0 {
t.Fatalf("rate-limited preview did not preserve both records: %+v", preview)
}
}
unchanged, err := a.Snapshot(ctx)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(before, unchanged.Data) {
t.Fatal("rate-limited previews changed canonical data")
}
a.mu.Lock()
result, err := a.importFacts(ctx, unchanged, []domain.Facts{sampleFacts("ALDI", "2026-09-10", "-12.34")}, nil)
a.mu.Unlock()
if err != nil {
t.Fatal(err)
}
if result.Imported != 1 || len(result.State.Data.Transactions) != 3 {
t.Fatal("provider cooldown lost the newly imported record")
}
for _, tx := range result.State.Data.Transactions {
if tx.Facts.ExternalID == hex.EncodeToString([]byte("ALDI")) {
if tx.Enrichment.Classification.Error == "" || tx.Enrichment.CategoryID != domain.ExpenseFallback {
t.Fatal("cooldown did not leave imported facts editable and unclassified")
}
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("previews and imports bypassed shared provider cooldown: %d requests", got)
}
}
func TestCancelledLastClassificationDoesNotProducePreview(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "60")
w.WriteHeader(http.StatusTooManyRequests)
cancel()
}))
defer provider.Close()
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
p, err := a.Preview(ctx, PreviewRequest{
Revision: s.Revision, From: "2026-09-09", To: "2026-09-09",
Model: "test/model", Fields: Fields{Category: true},
})
if !errors.Is(err, context.Canceled) || p.ID != "" {
t.Fatalf("cancelled final record produced a preview: id=%q, error=%v", p.ID, err)
}
after, err := a.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(s.Data, after.Data) {
t.Fatal("cancelled preview changed canonical data")
}
}
func mockClassifier(t *testing.T, a *App, inspect ...func(*http.Request)) {
t.Helper()
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, check := range inspect {
check(r)
}
var req struct {
Messages []struct {
Content string `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Error(err)
w.WriteHeader(400)
return
}
var prompt struct {
Categories []struct{ ID, Path string } `json:"categories"`
}
if len(req.Messages) != 2 || json.Unmarshal([]byte(req.Messages[1].Content), &prompt) != nil {
w.WriteHeader(400)
return
}
category := ""
for _, c := range prompt.Categories {
if strings.Contains(strings.ToLower(c.Path), "groceries") {
category = c.ID
}
}
content, _ := json.Marshal(map[string]any{"merchant_id": nil, "new_merchant": "REWE", "category_id": category, "tag_ids": []string{}, "confidence": "high"})
json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"finish_reason": "stop", "message": map[string]any{"content": string(content)}}}})
}))
t.Cleanup(mock.Close)
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: mock.URL}
}
func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
s, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error {
for i := range d.Transactions {
d.Transactions[i].Enrichment.TagIDs = []string{"home"}
}
return nil
})
if err != nil {
t.Fatal(err)
}
mockClassifier(t, a)
before := domain.Clone(s.Data)
preview, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "improved/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
if len(preview.Changes) != 2 || len(preview.Errors) != 0 {
t.Fatalf("unexpected preview: %+v", preview)
}
untouched, err := a.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(before, untouched.Data) {
t.Fatal("preview mutated canonical records")
}
id := preview.Changes[0].ID
applied, err := a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id})
if err != nil {
t.Fatal(err)
}
if len(applied.Data.Merchants) != len(before.Merchants) {
t.Fatal("category-only reclassification created merchants")
}
for i, tx := range applied.Data.Transactions {
if !reflect.DeepEqual(tx.Facts, before.Transactions[i].Facts) {
t.Fatal("financial facts changed")
}
if !reflect.DeepEqual(tx.Enrichment.TagIDs, before.Transactions[i].Enrichment.TagIDs) {
t.Fatal("unselected tags changed")
}
if tx.Facts.ID == id {
if tx.Enrichment.CategoryID != "groceries" || tx.Enrichment.Classification.Model != "improved/model" {
t.Fatal("selected category did not change")
}
} else if !reflect.DeepEqual(tx.Enrichment, before.Transactions[i].Enrichment) {
t.Fatal("unselected transaction changed")
}
}
if _, err = a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}); err == nil {
t.Fatal("consumed preview applied twice")
}
}
func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
mockClassifier(t, a)
p, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
if len(p.Changes) != 2 {
t.Fatalf("expected category changes before stale apply: %+v", p)
}
s, err = a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error { d.Transactions[0].Enrichment.TagIDs = []string{"home"}; return nil })
if err != nil {
t.Fatal(err)
}
if _, err = a.ApplyPreview(context.Background(), p.ID, p.Revision, []string{p.Changes[0].ID}); err == nil {
t.Fatal("stale preview overwrote manual edit")
}
after, err := a.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(s.Data, after.Data) {
t.Fatal("stale apply partially changed records")
}
}
func TestTaxonomyProposalApprovalMintsOnlyApprovedEntries(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
content := `{"categories":[{"name":"Food","parent":"","kind":"expense","hint":"Food purchases","because":["REWE"]},{"name":"Dining","parent":"Food","kind":"expense","hint":"Restaurants","because":["EDEKA"]}],"tags":[{"name":"Recurring","hint":"Repeats regularly"}],"merchants":[{"name":"REWE","aliases":["REWE"]}]}`
json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{
"finish_reason": "stop",
"message": map[string]any{"content": content},
}}})
}))
defer provider.Close()
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
preview, err := a.ProposeTaxonomy(context.Background(), TaxonomyProposalRequest{
Revision: s.Revision,
Model: "test/model",
})
if err != nil {
t.Fatal(err)
}
if len(preview.Sample) != 2 || len(preview.Proposal.Categories) != 2 {
t.Fatalf("unexpected taxonomy preview: %+v", preview)
}
approved := classification.TaxonomyProposal{
Categories: []classification.ProposedCategory{
preview.Proposal.Categories[1],
},
}
applied, err := a.ApplyTaxonomy(context.Background(), preview.ID, preview.Revision, approved)
if err != nil {
t.Fatal(err)
}
foundFood, foundDining := false, false
for _, category := range applied.Data.Categories {
foundFood = foundFood || category.Name == "Food"
foundDining = foundDining || category.Name == "Dining"
}
if !foundFood || !foundDining {
t.Fatalf("approved child did not bring its parent: %+v", applied.Data.Categories)
}
if len(applied.Data.Tags) != len(s.Data.Tags) || len(applied.Data.Merchants) != len(s.Data.Merchants) {
t.Fatal("unapproved taxonomy entries were written")
}
}