Files
Lars Nolden b7e5bf26cc Enforce name limits server-side and reject hidden runes in model names
Security review follow-ups. The 200-character registry-name cap the UI
forms promise now holds in domain.Validate for categories, tags,
merchants and instruments, so a non-browser client cannot persist an
unbounded name that every subsequent state response would carry. And a
model-supplied merchant or taxonomy name containing control or format
code points — bidi overrides, zero-width characters — is dropped like
an identifier-shaped one: React escaping already prevented injection,
but such names could visually spoof or reorder the review UI the
operator approves from.
2026-09-14 12:30:13 +02:00

654 lines
28 KiB
Go

package classification
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"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":[],"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
}
type classificationPrompt struct {
Categories []categoryPrompt `json:"categories"`
Tags []tagPrompt `json:"tags"`
Merchants []merchantPrompt `json:"merchants"`
History []promptHistory `json:"history"`
Transactions []struct {
Ref string `json:"ref"`
Counterparty string `json:"counterparty"`
Amount string `json:"amount"`
Currency string `json:"currency"`
} `json:"transactions"`
}
func decodeClassificationPrompt(t *testing.T, r *http.Request) classificationPrompt {
t.Helper()
var req struct {
Messages []struct {
Content string `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatal(err)
}
if len(req.Messages) != 2 {
t.Fatalf("expected system and user messages, got %d", len(req.Messages))
}
var prompt classificationPrompt
if err := json.Unmarshal([]byte(req.Messages[1].Content), &prompt); err != nil {
t.Fatal(err)
}
return prompt
}
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++
prompt := decodeClassificationPrompt(t, r)
// Food is an expense-only choice; do not reuse c1 after the request
// switches to income, where that reference names a different category.
categoryID := "c999"
for _, category := range prompt.Categories {
if category.Path == normalize(domain.CategoryPath(d, "cat_food")) {
categoryID = category.ID
}
if calls == 2 && category.Kind != "income" {
t.Errorf("income request offered an expense category: %+v", category)
}
}
reply(w, fmt.Sprintf(`{"merchant_id":null,"new_merchant":null,"category_id":%q,"tag_ids":[],"confidence":"medium"}`, categoryID))
})
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 || calls != 2 || 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":"high","unexpected":true}`,
"change kind": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high","kind":"transfer"}`,
"missing field": `{"merchant_id":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
"duplicate key": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","category_id":"c2","tag_ids":[],"confidence":"high"}`,
"case folded key": `{"Merchant_ID":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
"unknown category": `{"merchant_id":null,"new_merchant":null,"category_id":"c999","tag_ids":[],"confidence":"high"}`,
"canonical category": `{"merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"high"}`,
"unknown tag": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["t999"],"confidence":"high"}`,
"canonical tag": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["tag_daily"],"confidence":"high"}`,
"duplicate tags": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["t1","t1"],"confidence":"high"}`,
"null tags": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":null,"confidence":"high"}`,
"null tag member": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[null],"confidence":"high"}`,
"unknown merchant": `{"merchant_id":"m999","new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
"canonical merchant": `{"merchant_id":"mer_coffee","new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
"both merchant modes": `{"merchant_id":"m1","new_merchant":"Coffee","category_id":"c1","tag_ids":[],"confidence":"high"}`,
"blank proposal": `{"merchant_id":null,"new_merchant":" ","category_id":"c1","tag_ids":[],"confidence":"high"}`,
"wrong scalar": `{"merchant_id":23,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
"numeric confidence": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":0.9}`,
"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":"c1","tag_ids":["t1"],"confidence":"high"}`, "mer_coffee", false},
{"duplicate alias", `{"merchant_id":null,"new_merchant":"COFFEE-house","category_id":"c1","tag_ids":["t1"],"confidence":"high"}`, "mer_coffee", false},
{"new", `{"merchant_id":null,"new_merchant":"Bakery Lane","category_id":"c1","tag_ids":["t1"],"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")
}
wire, err := json.Marshal(captured)
if err != nil {
t.Fatal(err)
}
for _, canonicalID := range []string{"cat_food", "cat_expenses", "cat_income", "mer_coffee", "tag_daily"} {
if strings.Contains(string(wire), canonicalID) {
t.Errorf("request or response schema exposed canonical ID %q", canonicalID)
}
}
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), "Rent \u202Edeifirev \u2713", "zero\u200Bwidth"} {
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{}, "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)
}
})
}
}
// An upstream rate limit tunneled inside an HTTP 200 envelope must arm the
// shared cooldown like a transport 429: the next classification fails fast
// instead of pacing another request into a throttled endpoint.
func TestEnvelope429ArmsSharedCooldown(t *testing.T) {
f, d := fixture()
calls := 0
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
_, _ = io.WriteString(w, `{"error":{"code":429,"message":"private"},"choices":[]}`)
})
c.rate.Store(&ratelimit.Controller{InitialBackoff: time.Minute})
_, err := c.Classify(context.Background(), f, d, true)
var limit *ratelimit.RateLimitError
if err == nil || !errors.As(err, &limit) || strings.Contains(err.Error(), "private") {
t.Fatalf("envelope 429 not reported as a rate limit: %v", err)
}
if _, err = c.Classify(context.Background(), f, d, true); err == nil || !errors.As(err, &limit) {
t.Fatalf("cooldown not armed: %v", err)
}
if calls != 1 {
t.Fatalf("throttled endpoint was contacted again: %d calls", calls)
}
}
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 = "Z Distant Bakery"
before := domain.Clone(d)
for _, mode := range []string{"existing", "duplicate name"} {
t.Run(mode, func(t *testing.T) {
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
prompt := decodeClassificationPrompt(t, r)
if len(prompt.Merchants) != 35 || len(prompt.Tags) != 36 || len(prompt.Categories) != 37 {
t.Fatalf("complete candidates missing: merchants=%d tags=%d categories=%d", len(prompt.Merchants), len(prompt.Tags), len(prompt.Categories))
}
merchants, categories, tags := map[string]string{}, map[string]string{}, map[string]string{}
for _, merchant := range prompt.Merchants {
merchants[merchant.Name] = merchant.ID
}
for _, category := range prompt.Categories {
if category.Kind != "expense" {
t.Errorf("ineligible category candidate: %+v", category)
}
categories[category.Path] = category.ID
}
for _, tag := range prompt.Tags {
tags[tag.Name] = tag.ID
}
for _, merchant := range d.Merchants {
if merchants[normalize(merchant.Name)] == "" {
t.Errorf("merchant omitted: %s", merchant.Name)
}
}
for _, category := range d.Categories {
if category.Kind == "expense" && category.ID != "cat_expenses" && categories[normalize(domain.CategoryPath(d, category.ID))] == "" {
t.Errorf("eligible category omitted: %s", category.Name)
}
}
for _, tag := range d.Tags {
if tags[normalize(tag.Name)] == "" {
t.Errorf("tag omitted: %s", tag.Name)
}
}
var merchantID, newMerchant any = merchants["z distant bakery"], nil
if mode == "duplicate name" {
merchantID, newMerchant = nil, "Z Distant Bakery"
}
content, err := json.Marshal(map[string]any{
"merchant_id": merchantID,
"new_merchant": newMerchant,
"category_id": categories[normalize(domain.CategoryPath(d, "cat_34"))],
"tag_ids": []string{tags["tag 34"]},
"confidence": "high",
})
if err != nil {
t.Fatal(err)
}
reply(w, string(content))
})
p, err := c.Classify(context.Background(), f, d, true)
if err != nil || p.NewMerchant != 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("classification mutated the dataset")
}
})
}
}
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":"m1","new_merchant":null,"category_id":"c1","tag_ids":["t1"],"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 []merchantPrompt `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, fmt.Sprintf(`{"merchant_id":%q,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`, prompt.Merchants[0].ID))
})
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)
}
}