Azure is the only zero-data-retention route for the gpt-5.6 family, so its capacity 429s arrive frequently and OpenRouter forwards them inside an HTTP 200 envelope. Those bypassed the rate controller entirely: a paced run kept sending a request every three seconds into a throttled endpoint, failing row by row. An in-envelope 429 now records the same escalating cooldown as a transport 429, so later acquisitions fail fast until the deadline passes.
573 lines
24 KiB
Go
573 lines
24 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":"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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 = "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)
|
|
}
|
|
}
|