The gpt-5.6 family's zero-data-retention endpoints declare max_completion_tokens, so sending max_tokens under require_parameters excluded every ZDR route and returned HTTP 404 for the whole family. The cap is retired: the strict schema, the finish_reason check and the 64 KiB read cap already bound the response. The model fields now offer the provider's public ZDR catalog filtered by the exact conditions completions are routed under (live endpoint, strict structured outputs), fetched server-side, cached for an hour, and served at GET /api/models; the inputs stay free text so an unlisted model remains usable when the catalog is unreachable.
537 lines
23 KiB
Go
537 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 private_external private_fingerprint tx_private account_private ext_local_secret private_source Personal Checking Private Bank 550e8400-e29b-41d4-a716-446655440000 COBADEFFXXX ; 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 TestUnsafeMerchantProposalRejected(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": "c1", "tag_ids": []string{}})
|
|
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { reply(w, string(answer)) })
|
|
p, err := c.Classify(context.Background(), f, d, true)
|
|
if err == nil || p.NewMerchant != nil {
|
|
t.Fatalf("unsafe merchant accepted: %+v", p)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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) })
|
|
if p, err := c.Classify(context.Background(), f, d, true); err == nil || p.Enrichment.Classification.Source != "fallback" {
|
|
t.Fatalf("bad envelope accepted: %+v %v", p, 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 cobadeffxxx DE89370400440532013000")
|
|
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)
|
|
}
|
|
}
|