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":"cat_food","tag_ids":[],"confidence":"medium"}` 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 != "cat_food" { 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":"mer_coffee","new_merchant":null,"category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"high"}`, "mer_coffee", false}, {"duplicate alias", `{"merchant_id":null,"new_merchant":"COFFEE-house","category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"high"}`, "mer_coffee", false}, {"new", `{"merchant_id":null,"new_merchant":"Bakery Lane","category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"high"}`, "", 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 TestIdentifierOnlyPromptRedactionAndRouting(t *testing.T) { f, d := fixture() f.Counterparty = "Coffee House" 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 COBADEFFXXX private_external private_fingerprint tx_private account_private ext_local_secret private_source Personal Checking Private Bank 550e8400-e29b-41d4-a716-446655440000 ; reference secretpayment ; user@example.com" 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 struct { Transaction map[string]any `json:"transaction"` History []any `json:"history"` Categories []any `json:"categories"` Tags []any `json:"tags"` Merchants []any `json:"merchants"` } if err := json.Unmarshal([]byte(messages[1].Content), &prompt); err != nil { t.Fatal(err) } if len(prompt.Transaction) == 0 || len(prompt.Categories) == 0 || len(prompt.Merchants) == 0 { t.Fatal("complete structured prompt missing") } lower := strings.ToLower(messages[1].Content) for _, secret := range []string{"private_external", "private_fingerprint", "tx_private", "account_private", "ext_local_secret", "private_source", "personal checking", "550e8400", "cobadeff", "secretpayment", "example.com", "alice privateperson", "de89370400440532013000", "de44500105175407324931"} { if strings.Contains(lower, secret) { t.Errorf("prompt leaked %q", secret) } } for _, public := range []string{"coffee house", "918.27", "eur", "private bank"} { if !strings.Contains(lower, public) { t.Errorf("prompt omitted allowed value %q", public) } } 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") } if _, ok := captured["max_tokens"]; ok { t.Error("max_tokens excludes providers that only declare max_completion_tokens") } reply(w, validAnswer) }) c.PrivateNames = []string{"Alice Privateperson"} if _, err := c.Classify(context.Background(), f, d, true); err != nil { t.Fatal(err) } } func TestTransactionAmountAndCounterpartyAreSent(t *testing.T) { f, d := fixture() f.Counterparty = "Coffee House" 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 { Transaction struct { Amount string `json:"amount"` Currency string `json:"currency"` Counterparty string `json:"counterparty"` } `json:"transaction"` } _ = json.Unmarshal([]byte(req.Messages[1].Content), &prompt) if prompt.Transaction.Amount != string(f.Amount) || prompt.Transaction.Currency != "EUR" || prompt.Transaction.Counterparty != "coffee house" { t.Errorf("transaction context missing: %+v", prompt.Transaction) } reply(w, validAnswer) }) if _, err := c.Classify(context.Background(), f, d, true); err != nil { t.Fatal(err) } } func TestUnsafeMerchantProposalDroppedWithoutLosingClassification(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": "cat_food", "tag_ids": []string{}, "confidence": "high"}) c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { reply(w, string(answer)) }) c.PrivateNames = []string{"Alice Privateperson"} p, err := c.Classify(context.Background(), f, d, true) if err != nil { t.Fatalf("unsafe name must degrade, not fail the row: %v", err) } if p.NewMerchant != nil || p.Enrichment.MerchantID != "" { t.Fatalf("unsafe merchant stored: %+v", p) } if p.Enrichment.CategoryID != "cat_food" || p.Enrichment.Classification.Confidence != "high" { t.Fatalf("validated classification lost with the merchant: %+v", p.Enrichment) } }) } } 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) }) p, err := c.Classify(context.Background(), f, d, true) if err == nil || p.Enrichment.Classification.Source != "fallback" { t.Fatalf("bad envelope accepted: %+v %v", p, err) } if strings.Contains(err.Error(), "private") { t.Fatalf("provider text leaked into the error: %v", 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 TestCompleteRegistryPayloadAndGlobalDuplicateDetection(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, redactor(d, f, nil), redactor(d, f, nil)) if len(set.merchantIDs) != 35 || len(set.tags) != 36 { t.Fatalf("complete registry omitted entries: merchants=%d tags=%d", len(set.merchantIDs), len(set.tags)) } if set.merchantIDs["mer_34"] != "mer_34" || set.tagIDs["tag_34"] != "tag_34" || set.categoryIDs["cat_34"] != "cat_34" { t.Fatal("registry omitted real ids") } before := domain.Clone(d) c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { content, _ := json.Marshal(map[string]any{ "merchant_id": "mer_34", "new_merchant": nil, "category_id": "cat_34", "tag_ids": []string{"tag_34"}, "confidence": "high", }) reply(w, string(content)) }) p, err := c.Classify(context.Background(), f, d, true) if err != nil || p.Enrichment.MerchantID != "mer_34" || p.Enrichment.CategoryID != "cat_34" || !reflect.DeepEqual(p.Enrichment.TagIDs, []string{"tag_34"}) { t.Fatalf("complete registry selection failed: %+v %v", p, err) } 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 TestConfiguredPrivateNamesAndIdentifiersRedactWithoutRemovingPayee(t *testing.T) { f, d := fixture() f.Counterparty = "Coffee House" clean := redactor(d, f, []string{"Alice"}) text := clean("Alice Alice Alice Coffee House DE89370400440532013000 COBADEFFXXX") if strings.Contains(text, "alice") || strings.Contains(text, "cobadeff") || strings.Contains(text, "de893704") || !strings.Contains(text, "coffee house") { t.Fatalf("redaction: %q", text) } } func TestLowConfidenceKeepsProposalAndRecordsConfidence(t *testing.T) { f, d := fixture() c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { reply(w, `{"merchant_id":"mer_coffee","new_merchant":null,"category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"low"}`) }) p, err := c.Classify(context.Background(), f, d, true) if err != nil { t.Fatal(err) } // Review flows need the model's suggestion; discarding it is the import // path's decision, not the client's. if p.Enrichment.CategoryID != "cat_food" || p.Enrichment.MerchantID != "mer_coffee" || !reflect.DeepEqual(p.Enrichment.TagIDs, []string{"tag_daily"}) || p.Enrichment.Classification.Confidence != "low" { t.Fatalf("low-confidence proposal was not preserved: %+v", p) } } func TestLearnAliasIsIdempotentAndRejectsAmbiguity(t *testing.T) { f, d := fixture() f.Counterparty = "Coffee Shop Berlin" if !LearnAlias(&d, f, "mer_coffee") || LearnAlias(&d, f, "mer_coffee") { t.Fatal("unambiguous alias was not learned idempotently") } if len(d.Merchants[0].Aliases) != 2 { t.Fatalf("alias was duplicated: %+v", d.Merchants[0].Aliases) } d.Merchants = append(d.Merchants, domain.Merchant{ID: "mer_other", Name: "Other", Aliases: []string{"Shared Shop"}}, ) f.Counterparty = "Shared Shop" if LearnAlias(&d, f, "mer_coffee") { t.Fatal("ambiguous alias was learned") } } 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 TestPayeeAndPublicMerchantAreSentToAI(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 { Transaction struct { Description string `json:"description"` Counterparty string `json:"counterparty"` } `json:"transaction"` Merchants []candidate `json:"merchants"` } if err := json.Unmarshal([]byte(req.Messages[1].Content), &prompt); err != nil { t.Fatal(err) } if prompt.Transaction.Counterparty != "coffee house" { t.Errorf("payee was removed from transaction: %+v", prompt.Transaction) } if len(prompt.Merchants) != 26 || prompt.Merchants[0].Name != "coffee house" { t.Fatalf("complete merchant registry missing: %d", len(prompt.Merchants)) } reply(w, `{"merchant_id":"mer_coffee","new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"high"}`) }) 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) } 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) } }