package classification import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "reflect" "strings" "testing" "finance-duck/internal/domain" "finance-duck/internal/ratelimit" ) func fixture() (domain.Facts, domain.Dataset) { f := domain.Facts{ID: "tx_private", Source: "private_source", AccountID: "account_private", BookingDate: "2026-09-01", Amount: "-918.27", Currency: "EUR", RawDescription: "Coffee House", ExternalID: "private_external", Fingerprint: "private_fingerprint"} d := domain.NewDataset() d.Accounts = append(d.Accounts, domain.Account{ID: f.AccountID, DisplayName: "Personal Checking", Institution: "Private Bank", Currency: "EUR", Active: true}) d.Categories = append(d.Categories, domain.Category{ID: "cat_food", Name: "Food", ParentID: "cat_expenses", Kind: "expense"}) d.Tags = append(d.Tags, domain.Tag{ID: "tag_daily", Name: "Daily"}) d.Merchants = append(d.Merchants, domain.Merchant{ID: "mer_coffee", Name: "Coffee House", Aliases: []string{"coffee-house"}, DefaultCategoryID: "cat_food", DefaultTagIDs: []string{"tag_daily"}}) d.Transactions = append(d.Transactions, domain.Transaction{Facts: f, Enrichment: domain.Fallback(f)}) return f, d } const validAnswer = `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[]}` func reply(w http.ResponseWriter, content string) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"finish_reason": "stop", "message": map[string]any{"content": content}}}}) } func mockClient(t *testing.T, handler http.HandlerFunc) *Client { t.Helper() server := httptest.NewServer(handler) t.Cleanup(server.Close) client := &Client{APIKey: "test-secret", Model: "test/strict-model", BaseURL: server.URL, HTTPClient: server.Client()} client.rate.Store(&ratelimit.Controller{}) return client } func TestExplicitDefaultsAreOptInAndBypassAI(t *testing.T) { f, d := fixture() d.Merchants[0].UseDefaults = true f.RawDescription = "Payment COFFEE---house Berlin" before := domain.Clone(d) c := Client{} p, err := c.Classify(context.Background(), f, d, false) if err != nil { t.Fatal(err) } if p.Enrichment.MerchantID != "mer_coffee" || p.Enrichment.CategoryID != "cat_food" || !reflect.DeepEqual(p.Enrichment.TagIDs, []string{"tag_daily"}) || p.Enrichment.Classification.Source != "rule" { t.Fatalf("rule proposal: %+v", p) } p.Enrichment.TagIDs[0] = "changed" if !reflect.DeepEqual(before, d) { t.Fatal("caller dataset was mutated") } d.Merchants[0].UseDefaults = false p, err = c.Classify(context.Background(), f, d, false) if err == nil || p.Enrichment.CategoryID != domain.ExpenseFallback || len(p.Enrichment.TagIDs) != 0 || p.Enrichment.Classification.Source != "fallback" { t.Fatalf("defaults must require opt-in: %+v, %v", p, err) } } func TestForceAIOverridesRuleWithoutChangingKind(t *testing.T) { f, d := fixture() d.Merchants[0].UseDefaults = true calls := 0 c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { calls++; reply(w, validAnswer) }) p, err := c.Classify(context.Background(), f, d, true) if err != nil { t.Fatal(err) } if calls != 1 || p.Enrichment.Kind != "expense" || p.Enrichment.Classification.Source != "openrouter" || p.Enrichment.Classification.Model != c.Model || p.Enrichment.CategoryID != domain.ExpenseFallback { t.Fatalf("forced proposal: %+v, calls=%d", p, calls) } f.Amount = "918.27" p, err = c.Classify(context.Background(), f, d, true) if err != nil || p.Enrichment.Kind != "income" || p.Enrichment.CategoryID != domain.IncomeFallback { t.Fatalf("income sign: %+v %v", p, err) } } func TestInvalidRuleDoesNotFallThroughToAI(t *testing.T) { f, d := fixture() d.Merchants[0].UseDefaults = true d.Merchants[0].DefaultCategoryID = domain.IncomeFallback c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid rule must not silently send to AI") reply(w, validAnswer) }) p, err := c.Classify(context.Background(), f, d, false) if err == nil || p.Enrichment.CategoryID != domain.ExpenseFallback || p.Enrichment.MerchantID != "" { t.Fatalf("invalid rule must fail safely: %+v %v", p, err) } } func TestTransferNeverCallsAIOrAliases(t *testing.T) { f, d := fixture() d.Transactions[0].Enrichment = domain.Enrichment{Kind: "transfer", TransferPeerID: "tx_peer", TagIDs: []string{"tag_daily"}, Classification: domain.Provenance{Source: "manual"}} c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("transfer sent to AI") }) p, err := c.Classify(context.Background(), f, d, true) if err != nil || !reflect.DeepEqual(p.Enrichment, d.Transactions[0].Enrichment) { t.Fatalf("transfer changed: %+v %v", p, err) } p.Enrichment.TagIDs[0] = "modified" if d.Transactions[0].Enrichment.TagIDs[0] != "tag_daily" { t.Fatal("transfer proposal aliases dataset") } } func TestInvalidModelOutputsFailClosed(t *testing.T) { cases := map[string]string{ "unknown key": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":0.9}`, "change kind": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"kind":"transfer"}`, "missing field": `{"merchant_id":null,"category_id":"c1","tag_ids":[]}`, "duplicate key": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","category_id":"c2","tag_ids":[]}`, "case folded key": `{"Merchant_ID":null,"new_merchant":null,"category_id":"c1","tag_ids":[]}`, "unknown category": `{"merchant_id":null,"new_merchant":null,"category_id":"cat_invented","tag_ids":[]}`, "real ID not offered": `{"merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[]}`, "unknown tag": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["t999"]}`, "duplicate tags": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["t1","t1"]}`, "null tags": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":null}`, "null tag member": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[null]}`, "unknown merchant": `{"merchant_id":"m999","new_merchant":null,"category_id":"c1","tag_ids":[]}`, "both merchant modes": `{"merchant_id":"m1","new_merchant":"Coffee","category_id":"c1","tag_ids":[]}`, "blank proposal": `{"merchant_id":null,"new_merchant":" ","category_id":"c1","tag_ids":[]}`, "wrong scalar": `{"merchant_id":23,"new_merchant":null,"category_id":"c1","tag_ids":[]}`, "trailing JSON": validAnswer + ` {}`, "markdown": "```json\n" + validAnswer + "\n```", "array": "[" + validAnswer + "]", } for name, content := range cases { t.Run(name, func(t *testing.T) { f, d := fixture() before := domain.Clone(d) c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { reply(w, content) }) p, err := c.Classify(context.Background(), f, d, true) if err == nil || p.NewMerchant != nil || p.Enrichment.Kind != "expense" || p.Enrichment.CategoryID != domain.ExpenseFallback || p.Enrichment.Classification.Error == "" || p.Enrichment.Classification.Source != "fallback" { t.Fatalf("unsafe acceptance: %+v %v", p, err) } if !reflect.DeepEqual(d, before) { t.Fatal("rejected response mutated data") } }) } } func TestMerchantSelectionAndLocalProposal(t *testing.T) { cases := []struct { name, content, merchant string new bool }{ {"existing", `{"merchant_id":"m1","new_merchant":null,"category_id":"c2","tag_ids":["t1"]}`, "mer_coffee", false}, {"duplicate alias", `{"merchant_id":null,"new_merchant":"COFFEE-house","category_id":"c2","tag_ids":["t1"]}`, "mer_coffee", false}, {"new", `{"merchant_id":null,"new_merchant":"Bakery Lane","category_id":"c2","tag_ids":["t1"]}`, "", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f, d := fixture() before := domain.Clone(d) c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { reply(w, tc.content) }) p, err := c.Classify(context.Background(), f, d, true) if err != nil { t.Fatal(err) } if p.Enrichment.CategoryID != "cat_food" || !reflect.DeepEqual(p.Enrichment.TagIDs, []string{"tag_daily"}) { t.Fatalf("selection: %+v", p) } if tc.new { if p.NewMerchant == nil || p.NewMerchant.Name != "Bakery Lane" || p.NewMerchant.ID == "" || p.NewMerchant.ID != p.Enrichment.MerchantID || p.NewMerchant.UseDefaults || p.NewMerchant.DefaultCategoryID != "" { t.Fatalf("application-owned merchant: %+v", p) } } else if p.NewMerchant != nil || p.Enrichment.MerchantID != tc.merchant { t.Fatalf("existing merchant: %+v", p) } if !reflect.DeepEqual(before, d) { t.Fatal("successful proposal mutated data") } }) } } func TestPrivatePromptAllowlistAndRouting(t *testing.T) { f, d := fixture() f.Counterparty = "Alice Privateperson" f.CounterpartyIBAN = "DE89370400440532013000" d.Accounts[0].IBAN = "DE44500105175407324931" d.Accounts[0].ExternalAccountID = "ext_local_secret" f.RawDescription = "Coffee House -918.27 EUR Alice Privateperson DE89 3704 0044 0532 0130 00 private_external private_fingerprint tx_private account_private ext_local_secret private_source Personal Checking Private Bank 550e8400-e29b-41d4-a716-446655440000 COBADEFFXXX ; reference secretpayment ; user@example.com" d.Merchants[0].Name = "Coffee House Alice Privateperson" var captured map[string]json.RawMessage c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" || r.Header.Get("Authorization") != "Bearer test-secret" { t.Error("incorrect authenticated endpoint") } if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Error(err) } var provider struct { DataCollection string `json:"data_collection"` ZDR bool `json:"zdr"` Require bool `json:"require_parameters"` } _ = json.Unmarshal(captured["provider"], &provider) if provider.DataCollection != "deny" || !provider.ZDR || !provider.Require { t.Error("privacy routing relaxed") } var messages []struct{ Role, Content string } _ = json.Unmarshal(captured["messages"], &messages) if len(messages) != 2 { t.Fatal("unexpected messages") } var prompt map[string]json.RawMessage _ = json.Unmarshal([]byte(messages[1].Content), &prompt) for key := range prompt { switch key { case "description", "categories", "tags", "merchants": default: t.Errorf("non-allowlisted prompt key %q", key) } } lower := strings.ToLower(messages[1].Content) for _, secret := range []string{"918", "27", "alice", "privateperson", "3704", "private_external", "private_fingerprint", "tx_private", "account_private", "ext_local_secret", "private_source", "personal checking", "private bank", "550e8400", "cobadeff", "secretpayment", "example.com", "mer_coffee", "cat_food", "tag_daily"} { if strings.Contains(lower, secret) { t.Errorf("prompt leaked %q", secret) } } var format struct { Type string `json:"type"` Schema struct { Strict bool `json:"strict"` Schema map[string]any `json:"schema"` } `json:"json_schema"` } _ = json.Unmarshal(captured["response_format"], &format) if format.Type != "json_schema" || !format.Schema.Strict || format.Schema.Schema["additionalProperties"] != false { t.Error("non-strict request") } if _, ok := captured["plugins"]; ok { t.Error("plugins leak outside privacy policy") } reply(w, validAnswer) }) if _, err := c.Classify(context.Background(), f, d, true); err != nil { t.Fatal(err) } } func TestAmountRequiresExplicitOptIn(t *testing.T) { f, d := fixture() c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { var req struct { Messages []struct { Content string `json:"content"` } `json:"messages"` } _ = json.NewDecoder(r.Body).Decode(&req) var prompt struct { Amount domain.Money `json:"amount"` Currency string `json:"currency"` } _ = json.Unmarshal([]byte(req.Messages[1].Content), &prompt) if prompt.Amount != f.Amount || prompt.Currency != "EUR" { t.Errorf("explicit amount missing: %+v", prompt) } reply(w, validAnswer) }) c.IncludeAmount = true if _, err := c.Classify(context.Background(), f, d, true); err != nil { t.Fatal(err) } } func TestUnsafeMerchantProposalRejected(t *testing.T) { for _, name := range []string{"Alice Privateperson", "DE89370400440532013000", "Bank 123456789", "reference secretpayment", strings.Repeat("x", 101)} { t.Run(name, func(t *testing.T) { f, d := fixture() f.Counterparty = "Alice Privateperson" answer, _ := json.Marshal(map[string]any{"merchant_id": nil, "new_merchant": name, "category_id": "c1", "tag_ids": []string{}}) c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { reply(w, string(answer)) }) p, err := c.Classify(context.Background(), f, d, true) if err == nil || p.NewMerchant != nil { t.Fatalf("unsafe merchant accepted: %+v", p) } }) } } func TestProviderErrorsNeverRelaxPolicyOrEchoResponse(t *testing.T) { for _, status := range []int{302, 400, 401, 402, 403, 404, 500, 503} { t.Run(fmt.Sprint(status), func(t *testing.T) { f, d := fixture() calls := 0 c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ w.Header().Set("Location", "/redirect") w.WriteHeader(status) _, _ = io.WriteString(w, "sensitive-provider-response") }) p, err := c.Classify(context.Background(), f, d, true) if err == nil || calls != 1 || strings.Contains(err.Error(), "sensitive") || strings.Contains(p.Enrichment.Classification.Error, "sensitive") { t.Fatalf("unsafe provider handling: %+v %v calls=%d", p, err, calls) } }) } } func TestMalformedEnvelopesRejected(t *testing.T) { bodies := []string{ `{}`, `{"error":{"message":"private"},"choices":[]}`, `{"choices":[{"finish_reason":"length","message":{"content":"{}"}}]}`, `{"choices":[{"finish_reason":"stop","message":{"content":"{}","refusal":"private"}}]}`, `{"choices":[{"finish_reason":"stop","message":{"content":"{}","tool_calls":[{}]}}]}`, strings.Repeat("x", 64*1024+1), } for i, body := range bodies { t.Run(fmt.Sprint(i), func(t *testing.T) { f, d := fixture() c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, body) }) if p, err := c.Classify(context.Background(), f, d, true); err == nil || p.Enrichment.Classification.Source != "fallback" { t.Fatalf("bad envelope accepted: %+v %v", p, err) } }) } } type failingTransport struct{} func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) { return nil, errors.New("private-network-details") } func TestTransportFailureAndInsecureEndpointAreSafe(t *testing.T) { f, d := fixture() c := Client{APIKey: "key", Model: "model", HTTPClient: &http.Client{Transport: failingTransport{}}} p, err := c.Classify(context.Background(), f, d, true) if err == nil || strings.Contains(err.Error(), "private-network-details") || p.Enrichment.Classification.Error == "" { t.Fatalf("unsafe transport error: %+v %v", p, err) } c.BaseURL = "http://nonlocal.example/api/v1" if _, err = c.Classify(context.Background(), f, d, true); err == nil || !strings.Contains(err.Error(), "HTTPS") { t.Fatalf("insecure endpoint: %v", err) } } func TestBoundedCandidatesAndGlobalDuplicateDetection(t *testing.T) { f, d := fixture() d.Merchants = nil for i := range 35 { d.Merchants = append(d.Merchants, domain.Merchant{ID: fmt.Sprintf("mer_%02d", i), Name: fmt.Sprintf("Merchant %02d", i), Aliases: []string{}, DefaultTagIDs: []string{}}) d.Tags = append(d.Tags, domain.Tag{ID: fmt.Sprintf("tag_%02d", i), Name: fmt.Sprintf("Tag %02d", i)}) d.Categories = append(d.Categories, domain.Category{ID: fmt.Sprintf("cat_%02d", i), Name: fmt.Sprintf("Category %02d", i), Kind: "expense", ParentID: "cat_expenses"}) } d.Merchants[34].Name = "Distant Bakery" set := retrieve(f.RawDescription, "expense", d, newSanitizer(f, d, false), newSanitizer(f, d, true)) if len(set.categories) != 37 || len(set.tags) != 36 || len(set.merchants) != 20 { t.Fatal("merchant bound or complete leaf taxonomy violated") } if set.categoryIDs["c1"] != domain.ExpenseFallback { t.Fatal("fallback omitted from candidate set") } for _, id := range set.merchantIDs { if id == "mer_34" { t.Fatal("fixture duplicate should be outside bounded candidates") } } before := domain.Clone(d) c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { tagIDs := make([]string, 36) for i := range tagIDs { tagIDs[i] = fmt.Sprintf("t%d", i+1) } content, _ := json.Marshal(map[string]any{"merchant_id": nil, "new_merchant": "distant-bakery", "category_id": "c37", "tag_ids": tagIDs}) reply(w, string(content)) }) p, err := c.Classify(context.Background(), f, d, true) if err != nil || p.NewMerchant != nil || p.Enrichment.MerchantID != "mer_34" { t.Fatalf("global duplicate missed: %+v %v", p, err) } if p.Enrichment.CategoryID != "cat_food" || len(p.Enrichment.TagIDs) != 36 { t.Fatalf("taxonomy beyond first twenty unavailable: %+v", p.Enrichment) } if !reflect.DeepEqual(before, d) { t.Fatal("retrieval mutated registry order") } } func TestAliasBoundariesSpecificityAndAmbiguity(t *testing.T) { merchants := []domain.Merchant{{ID: "a", Name: "Shell"}, {ID: "b", Name: "Shell Cafe"}, {ID: "c", Name: "Elsewhere", Aliases: []string{"same alias"}}, {ID: "d", Name: "Other", Aliases: []string{"SAME-ALIAS"}}} if m := aliasMatch("Seashell", merchants); m != nil { t.Fatal("substring alias matched") } if m := aliasMatch("SHELL--CAFE Berlin", merchants); m == nil || m.ID != "b" { t.Fatal("most specific alias did not win") } if m := aliasMatch("same alias", merchants); m != nil { t.Fatal("ambiguous alias automatically applied") } } func TestNearMerchantDeduplicationIsConservative(t *testing.T) { merchants := []domain.Merchant{{ID: "coffee", Name: "Coffee House"}, {ID: "rewe", Name: "REWE"}} if m := duplicateMerchant("Coffee Hous", merchants); m == nil || m.ID != "coffee" { t.Fatal("unambiguous high-similarity spelling missed") } if m := duplicateMerchant("REWE To Go", merchants); m != nil { t.Fatal("distinct merchant variant conflated") } merchants = []domain.Merchant{{ID: "one", Name: "Coffee House Berlin"}, {ID: "two", Name: "Coffee House Berli"}} if m := duplicateMerchant("Coffee House Berl", merchants); m != nil { t.Fatal("ambiguous similarity must not pick a merchant") } } func TestRepeatedPrivateValuesAreAllRedacted(t *testing.T) { f, d := fixture() f.Counterparty = "Alice" clean := newSanitizer(f, d, false) text := clean("Alice Alice Alice Coffee House cobadeffxxx") if strings.Contains(text, "alice") || strings.Contains(text, "cobadeff") || !strings.Contains(text, "coffee house") { t.Fatalf("redaction: %q", text) } } func TestPayeeAliasDefaultsRemainEntirelyLocal(t *testing.T) { f, d := fixture() f.RawDescription = "Card payment reference" f.Counterparty = "COFFEE---HOUSE" d.Merchants[0].UseDefaults = true c := Client{} p, err := c.Classify(context.Background(), f, d, false) if err != nil || p.Enrichment.MerchantID != "mer_coffee" || p.Enrichment.CategoryID != "cat_food" || p.Enrichment.Classification.Source != "rule" { t.Fatalf("local payee rule missed: %+v %v", p, err) } } func TestPayeeRanksPublicMerchantWithoutExposingRawPayee(t *testing.T) { f, d := fixture() f.RawDescription = "Card payment Coffee House" f.Counterparty = "Coffee House" for i := range 25 { d.Merchants = append(d.Merchants, domain.Merchant{ID: fmt.Sprintf("mer_a_%02d", i), Name: fmt.Sprintf("Other %d", i)}) } c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { var req struct { Messages []struct { Content string `json:"content"` } `json:"messages"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatal(err) } var prompt struct { Description string `json:"description"` Merchants []candidate `json:"merchants"` } if err := json.Unmarshal([]byte(req.Messages[1].Content), &prompt); err != nil { t.Fatal(err) } if strings.Contains(prompt.Description, "coffee") || strings.Contains(req.Messages[1].Content, "counterparty") { t.Error("raw payee exposed") } if len(prompt.Merchants) != 20 || prompt.Merchants[0].Name != "coffee house" { t.Fatalf("public canonical merchant was redacted or missed: %+v", prompt.Merchants) } reply(w, `{"merchant_id":"m1","new_merchant":null,"category_id":"c1","tag_ids":[]}`) }) p, err := c.Classify(context.Background(), f, d, true) if err != nil || p.Enrichment.MerchantID != "mer_coffee" { t.Fatalf("payee merchant selection: %+v %v", p, err) } // Ranking must also work when only the local payee, not description, identifies it. f.RawDescription = "Card payment" p, err = c.Classify(context.Background(), f, d, true) if err != nil || p.Enrichment.MerchantID != "mer_coffee" { t.Fatalf("payee-only retrieval: %+v %v", p, err) } }