205 lines
7.0 KiB
Go
205 lines
7.0 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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")})
|
|
a.mu.Unlock()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return result.State
|
|
}
|
|
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")})
|
|
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 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, Name 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.Name), "groceries") {
|
|
category = c.ID
|
|
}
|
|
}
|
|
content, _ := json.Marshal(map[string]any{"merchant_id": nil, "new_merchant": "REWE", "category_id": category, "tag_ids": []string{}})
|
|
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")
|
|
}
|
|
}
|