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) } } // runPreview drives the background preview job to completion the way the UI // does: start the run, then poll progress until it reports done. func runPreview(t *testing.T, a *App, r PreviewRequest) (Preview, error) { t.Helper() start, err := a.StartPreview(context.Background(), r) if err != nil { return Preview{}, err } deadline := time.Now().Add(15 * time.Second) for { p, err := a.PreviewProgress(start.ID) if err != nil { return Preview{}, err } if p.Done { if p.Error != "" { return Preview{}, errors.New(p.Error) } return *p.Preview, nil } if time.Now().After(deadline) { t.Fatal("preview run did not finish") } time.Sleep(5 * time.Millisecond) } } 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 := runPreview(t, a, 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 TestCancelledPreviewRunProducesNoPreview(t *testing.T) { a, s := testApp(t) s = seed(t, a, s) ids := make(chan string, 1) provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Stop the run from within its first provider call, as the UI's Stop // button would mid-request. a.CancelPreview(<-ids) w.Header().Set("Retry-After", "60") w.WriteHeader(http.StatusTooManyRequests) })) defer provider.Close() a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL} start, err := a.StartPreview(context.Background(), PreviewRequest{ Revision: s.Revision, From: "2026-09-09", To: "2026-09-09", Model: "test/model", Fields: Fields{Category: true}, }) if err != nil { t.Fatal(err) } ids <- start.ID deadline := time.Now().Add(10 * time.Second) for { if _, err := a.PreviewProgress(start.ID); err != nil { break // the cancelled run is gone, never a finished preview } if time.Now().After(deadline) { t.Fatal("cancelled preview run still reports progress") } time.Sleep(5 * time.Millisecond) } if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}, nil); err == nil { t.Fatal("cancelled run produced an applicable preview") } 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 { Transactions []struct { Ref string `json:"ref"` } `json:"transactions"` 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 } } answer := map[string]any{"merchant_id": nil, "new_merchant": "REWE", "category_id": category, "tag_ids": []string{}, "confidence": "high"} var content []byte if len(prompt.Transactions) > 0 { items := make([]map[string]any, 0, len(prompt.Transactions)) for _, row := range prompt.Transactions { item := map[string]any{"ref": row.Ref} for k, v := range answer { item[k] = v } items = append(items, item) } content, _ = json.Marshal(map[string]any{"transactions": items}) } else { content, _ = json.Marshal(answer) } 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 := runPreview(t, a, 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}, nil) 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}, nil); 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 := runPreview(t, a, 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}, nil); 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") } } // A preview run is minutes long by design (paced provider calls), so a // scheduled sync, an import, or an earlier partial apply committing in the // meantime must not invalidate the review: only an edit to a selected // transaction itself conflicts. func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) { ctx := context.Background() a, s := testApp(t) s = seed(t, a, s) mockClassifier(t, a) p, err := runPreview(t, a, 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 two proposed changes: %+v", p) } // An unrelated registry edit moves the journal revision after the preview. if _, err = a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error { d.Tags = append(d.Tags, domain.Tag{ID: "travel", Name: "travel"}) return nil }); err != nil { t.Fatal(err) } first, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}, nil) if err != nil { t.Fatalf("unrelated commit invalidated the preview: %v", err) } // The partial apply moved the revision again; the remaining proposal must // still apply without another paced provider run. second, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[1].ID}, nil) if err != nil { t.Fatalf("partial apply consumed the remaining proposals: %v", err) } if second.Revision == first.Revision { t.Fatal("second apply committed nothing") } for _, tx := range second.Data.Transactions { if tx.Enrichment.CategoryID != "groceries" { t.Fatalf("applied categories lost: %+v", tx.Enrichment) } } // Both changes are consumed now; re-applying must fail, not double-write. if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}, nil); err == nil { t.Fatal("consumed change applied twice") } } // A reviewer can correct a proposal before applying it: the corrected fields // land instead of the model's, provenance becomes manual, and an invalid or // unselected correction rejects the whole apply. func TestApplyPreviewHonoursReviewerEdits(t *testing.T) { ctx := context.Background() a, s := testApp(t) s = seed(t, a, s) s, err := a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error { d.Categories = append(d.Categories, domain.Category{ID: "dining", Name: "Dining", ParentID: "cat_expenses", Kind: "expense"}) return nil }) if err != nil { t.Fatal(err) } mockClassifier(t, a) p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true, Tags: true}}) if err != nil { t.Fatal(err) } if len(p.Changes) != 2 { t.Fatalf("expected two proposed changes: %+v", p) } edited, other := p.Changes[0], p.Changes[1] if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID}, []EnrichmentEdit{{ID: edited.ID, CategoryID: "nonexistent", TagIDs: []string{}}}); err == nil { t.Fatal("edit naming an unknown category was applied") } if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID}, []EnrichmentEdit{{ID: other.ID, CategoryID: "dining", TagIDs: []string{}}}); err == nil { t.Fatal("edit for an unselected transaction was accepted") } applied, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID, other.ID}, []EnrichmentEdit{{ID: edited.ID, CategoryID: "dining", TagIDs: []string{"home"}}}) if err != nil { t.Fatal(err) } for _, tx := range applied.Data.Transactions { e := tx.Enrichment switch tx.Facts.ID { case edited.ID: if e.CategoryID != "dining" || !reflect.DeepEqual(e.TagIDs, []string{"home"}) { t.Fatalf("reviewer correction lost: %+v", e) } if e.Classification.Source != "manual" { t.Fatalf("corrected change kept model provenance: %+v", e.Classification) } case other.ID: if e.CategoryID != "groceries" || e.Classification.Source == "manual" { t.Fatalf("uncorrected change altered: %+v", e) } } } } // Imports auto-apply only what the model is sure about: a low-confidence // category lands on the editable fallback while the merchant link and the // recorded confidence survive for review in Analyse. func TestImportNeverAutoAppliesLowConfidenceCategory(t *testing.T) { a, s := testApp(t) provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { content := `{"merchant_id":null,"new_merchant":"REWE","category_id":"groceries","tag_ids":[],"confidence":"low"}` 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} s = seed(t, a, s) if len(s.Data.Transactions) != 2 { t.Fatalf("import lost transactions: %d", len(s.Data.Transactions)) } for _, tx := range s.Data.Transactions { e := tx.Enrichment if e.CategoryID != domain.ExpenseFallback { t.Fatalf("low-confidence category was auto-applied: %+v", e) } if e.MerchantID == "" || e.Classification.Confidence != "low" || e.Classification.Source != "openrouter" { t.Fatalf("merchant link or provenance lost: %+v", e) } } } 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") } }