package classification import ( "context" "encoding/json" "errors" "io" "net/http" "reflect" "strings" "testing" "time" "finance-duck/internal/domain" "finance-duck/internal/ratelimit" ) func batchRows() (domain.Facts, domain.Facts, domain.Dataset) { f1, d := fixture() f1.Counterparty = "Coffee House" f2 := f1 f2.ID, f2.Fingerprint, f2.ExternalID = "tx_two", "fp_two", "ext_two" f2.Amount = "-4.30" f2.Counterparty = "Kleins Backstube" return f1, f2, d } // One request classifies every row: the prompt carries all transactions with // refs, and each answer resolves independently against the registry. func TestBatchClassifiesEveryRowInOneRequest(t *testing.T) { f1, f2, d := batchRows() calls := 0 c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ var req struct { Messages []struct { Content string `json:"content"` } `json:"messages"` } if json.NewDecoder(r.Body).Decode(&req) != nil || len(req.Messages) != 2 { w.WriteHeader(400) return } var prompt struct { Transactions []struct{ Ref, Counterparty, Amount, Currency string } `json:"transactions"` } if json.Unmarshal([]byte(req.Messages[1].Content), &prompt) != nil || len(prompt.Transactions) != 2 { t.Errorf("batch prompt missing transactions: %s", req.Messages[1].Content) } for _, row := range prompt.Transactions { if row.Amount == "" || row.Currency != "EUR" { t.Errorf("row %s lost amount or currency: %+v", row.Ref, row) } } reply(w, `{"transactions":[{"ref":"r1","merchant_id":"mer_coffee","new_merchant":null,"category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"high"},`+ `{"ref":"r2","merchant_id":null,"new_merchant":"Kleins Backstube","category_id":"cat_food","tag_ids":[],"confidence":"medium"}]}`) }) results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d) if calls != 1 { t.Fatalf("expected one provider request for the batch, got %d", calls) } if results[0].Err != nil || results[1].Err != nil { t.Fatalf("batch rows failed: %v %v", results[0].Err, results[1].Err) } first := results[0].Proposal.Enrichment if first.MerchantID != "mer_coffee" || first.CategoryID != "cat_food" || !reflect.DeepEqual(first.TagIDs, []string{"tag_daily"}) || first.Classification.Confidence != "high" { t.Fatalf("first row lost: %+v", first) } second := results[1].Proposal if second.NewMerchant == nil || second.NewMerchant.Name != "Kleins Backstube" || !reflect.DeepEqual(second.NewMerchant.Aliases, []string{"Kleins Backstube"}) || second.Enrichment.MerchantID != second.NewMerchant.ID || second.Enrichment.Classification.Confidence != "medium" { t.Fatalf("second row lost: %+v", second) } } // One row's out-of-registry answer fails only that row. func TestBatchIsolatesInvalidRows(t *testing.T) { f1, f2, d := batchRows() c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { reply(w, `{"transactions":[{"ref":"r1","merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"high"},`+ `{"ref":"r2","merchant_id":null,"new_merchant":null,"category_id":"cat_forged","tag_ids":[],"confidence":"high"}]}`) }) results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d) if results[0].Err != nil || results[0].Proposal.Enrichment.CategoryID != "cat_food" { t.Fatalf("healthy row poisoned: %+v", results[0]) } if results[1].Err == nil || results[1].Proposal.Enrichment.Classification.Source != "fallback" { t.Fatalf("forged category accepted: %+v", results[1]) } } // Two rows naming the same new business share one minted merchant. func TestBatchSharesOneMintedMerchant(t *testing.T) { f1, f2, d := batchRows() c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { reply(w, `{"transactions":[{"ref":"r1","merchant_id":null,"new_merchant":"REWE","category_id":"cat_food","tag_ids":[],"confidence":"high"},`+ `{"ref":"r2","merchant_id":null,"new_merchant":"REWE","category_id":"cat_food","tag_ids":[],"confidence":"high"}]}`) }) results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d) if results[0].Err != nil || results[1].Err != nil { t.Fatalf("batch failed: %v %v", results[0].Err, results[1].Err) } a, b := results[0].Proposal, results[1].Proposal if a.NewMerchant == nil || b.NewMerchant == nil || a.NewMerchant.ID != b.NewMerchant.ID || a.Enrichment.MerchantID != b.Enrichment.MerchantID { t.Fatalf("duplicate merchants minted: %+v %+v", a.NewMerchant, b.NewMerchant) } } // A request-level rate limit fails every row and arms the shared cooldown. func TestBatchRateLimitFailsAllRowsAndArmsCooldown(t *testing.T) { f1, f2, d := batchRows() 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}) results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d) var limit *ratelimit.RateLimitError for _, result := range results { if result.Err == nil || !errors.As(result.Err, &limit) || strings.Contains(result.Err.Error(), "private") { t.Fatalf("row not failed as rate limit: %v", result.Err) } } again := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d) if again[0].Err == nil || !errors.As(again[0].Err, &limit) || calls != 1 { t.Fatalf("cooldown not armed: %v after %d calls", again[0].Err, calls) } } // A provider that rejects large schemas outright (Gemini's complexity cap // scales with the registry) must not fail the rows: the chunk halves until // accepted and the client remembers the working size. func TestBatchSplitsOnProviderSchemaRejection(t *testing.T) { f1, f2, d := batchRows() f3 := f1 f3.ID, f3.Fingerprint, f3.Counterparty = "tx_three", "fp_three", "Aral" f4 := f1 f4.ID, f4.Fingerprint, f4.Counterparty = "tx_four", "fp_four", "ALDI" calls, oversized := 0, 0 c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ var req struct { Messages []struct { Content string `json:"content"` } `json:"messages"` } if json.NewDecoder(r.Body).Decode(&req) != nil { w.WriteHeader(500) return } var prompt struct { Transactions []struct{ Ref string } `json:"transactions"` } _ = json.Unmarshal([]byte(req.Messages[1].Content), &prompt) if len(prompt.Transactions) > 2 { oversized++ w.WriteHeader(400) return } answers := make([]string, 0, len(prompt.Transactions)) for _, row := range prompt.Transactions { answers = append(answers, `{"ref":"`+row.Ref+`","merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"high"}`) } reply(w, `{"transactions":[`+strings.Join(answers, ",")+`]}`) }) results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2, f3, f4}, d) for i, result := range results { if result.Err != nil || result.Proposal.Enrichment.CategoryID != "cat_food" { t.Fatalf("row %d lost to schema rejection: %+v", i, result) } } if oversized != 1 || calls != 3 { t.Fatalf("expected one rejected probe then two halves, got %d calls (%d oversized)", calls, oversized) } if c.batchCap() != 2 { t.Fatalf("working batch size not learned: %d", c.batchCap()) } // The learned cap is respected up front on the next batch. before := calls _ = c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2, f3, f4}, d) if calls-before != 2 { t.Fatalf("learned cap ignored: %d extra calls", calls-before) } }