Files
finance-duck/internal/app/app_test.go
T

618 lines
22 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)
}
}
// 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 TestPreviewExpiresAfterTwentyFourHours(t *testing.T) {
for _, tc := range []struct {
name string
age time.Duration
newPreview bool
expired bool
}{
{name: "apply before expiry", age: 24*time.Hour - time.Minute},
{name: "apply after expiry", age: 24*time.Hour + time.Minute, expired: true},
{name: "new preview retains unexpired review", age: 24*time.Hour - time.Minute, newPreview: true},
{name: "new preview discards expired review", age: 24*time.Hour + time.Minute, newPreview: true, expired: true},
} {
t.Run(tc.name, func(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)
}
a.mu.Lock()
p.created = time.Now().Add(-tc.age)
a.previews[p.ID] = p
a.mu.Unlock()
if tc.newPreview {
// Completing another run performs expired-preview cleanup.
// An empty range needs no additional provider request.
if _, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2025-01-01", To: "2025-01-31", Model: "test/model", Fields: Fields{Category: true}}); err != nil {
t.Fatal(err)
}
}
change := p.Changes[0]
_, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{change.ID}, nil)
if (err != nil) != tc.expired {
t.Fatalf("apply at age %s: error = %v, expired = %t", tc.age, err, tc.expired)
}
after, err := a.Snapshot(ctx)
if err != nil {
t.Fatal(err)
}
expected := domain.Clone(s.Data)
if !tc.expired {
for i := range expected.Transactions {
if expected.Transactions[i].Facts.ID == change.ID {
expected.Transactions[i].Enrichment = change.After
}
}
}
if !reflect.DeepEqual(after.Data, expected) {
t.Fatal("expiry handling did not preserve the expected transaction state")
}
})
}
}
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":"c1","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")
}
}