Strict structured-output mode rejects uniqueItems, so every request to a gpt-5.6-family zero-data-retention endpoint failed with HTTP 400 behind a generic error; duplicates were already rejected server-side, so the keyword leaves the wire schemas, pinned by a strict-keyword allowlist test built from the ledger that hit this. The bare-BIC redaction pattern deleted every 8- and 11-letter word — Openbank, BAUMARKT, RACETRACKER — blinding the model to the payee it was asked to classify and tripping the unsafe-merchant check on honest answers. BICs now die only labeled or attached to their IBAN, account labels join the redaction secrets, an identifier-shaped merchant name degrades to a merchant-less proposal instead of failing the row, and a provider error inside an HTTP 200 envelope is reported as such (numeric code only) instead of as envelope corruption.
548 lines
23 KiB
Go
548 lines
23 KiB
Go
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)
|
|
}
|
|
}
|