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.
654 lines
28 KiB
Go
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)
|
|
}
|
|
}
|