hsa-app/internal/classify/classify_test.go

157 lines
4.4 KiB
Go
Raw Permalink Normal View History

package classify
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"maisym.com/hsa/internal/config"
)
func testCatalog() ([]config.Person, []config.Category) {
persons := []config.Person{
{First: "Jean-Michel", Last: "Tremblay"},
{First: "Lynna", Last: "Nguyen"},
{First: "Jude", Last: "Tremblay"},
}
categories := []config.Category{
{Name: "Medical", Examples: []string{"clinic"}},
{Name: "Pharmacy", Examples: []string{"CVS"}},
{Name: "Other"},
}
return persons, categories
}
// mockServer returns an httptest server that replies with a tool_use block whose
// input is the given map, and the Classifier pointed at it.
func mockServer(t *testing.T, input map[string]any) (*Classifier, *httptest.Server) {
t.Helper()
persons, categories := testCatalog()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Sanity: the request must force our tool.
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), toolName) {
t.Errorf("request missing tool %q: %s", toolName, body)
}
resp := map[string]any{
"content": []map[string]any{
{"type": "tool_use", "name": toolName, "input": input},
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
t.Cleanup(srv.Close)
c := New("test-key", "claude-haiku-4-5-20251001", persons, categories)
c.Endpoint = srv.URL
c.HTTP = srv.Client()
return c, srv
}
func TestClassifyHappyPath(t *testing.T) {
c, _ := mockServer(t, map[string]any{
"person": "Lynna Nguyen",
"category": "Pharmacy",
"date": "2025-11-03",
"amount": "42.50",
"raw_name": "Nguyen, Lynna",
"raw_date": "11/03/25",
"raw_amount": "$42.50",
})
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg", nil)
if err != nil {
t.Fatalf("Classify: %v", err)
}
if got.Person == nil || *got.Person != "Lynna Nguyen" {
t.Errorf("person = %v, want Lynna Nguyen", got.Person)
}
if got.Category != "Pharmacy" {
t.Errorf("category = %q, want Pharmacy", got.Category)
}
if got.Date == nil || *got.Date != "2025-11-03" {
t.Errorf("date = %v, want 2025-11-03", got.Date)
}
if got.Amount == nil || *got.Amount != "42.50" {
t.Errorf("amount = %v, want 42.50", got.Amount)
}
if got.RawName != "Nguyen, Lynna" {
t.Errorf("raw_name = %q", got.RawName)
}
}
func TestClassifyNullsAndFallback(t *testing.T) {
c, _ := mockServer(t, map[string]any{
"person": nil,
"category": "definitely-not-a-real-category",
"date": nil,
"amount": "null", // stringified null
"raw_name": "",
"raw_date": "",
"raw_amount": "",
})
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg", nil)
if err != nil {
t.Fatalf("Classify: %v", err)
}
if got.Person != nil {
t.Errorf("person = %v, want nil", *got.Person)
}
if got.Date != nil {
t.Errorf("date = %v, want nil", *got.Date)
}
if got.Amount != nil {
t.Errorf("amount = %v, want nil (stringified null)", *got.Amount)
}
if got.Category != "Other" { // fallback to most general
t.Errorf("category = %q, want Other fallback", got.Category)
}
}
func TestClassifyRejectsNonCanonicalPerson(t *testing.T) {
c, _ := mockServer(t, map[string]any{
"person": "Dr. Emily Smith", // a provider, not a configured patient
"category": "Medical",
})
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg", nil)
if err != nil {
t.Fatalf("Classify: %v", err)
}
if got.Person != nil {
t.Errorf("person = %v, want nil (non-canonical)", *got.Person)
}
}
// TestClassifyIntegration hits the real API with the cheapest model. Skipped unless
// HSA_CLASSIFY_IT=1 and CLAUDE_API_KEY are set, so normal test runs cost nothing.
func TestClassifyIntegration(t *testing.T) {
if os.Getenv("HSA_CLASSIFY_IT") != "1" {
t.Skip("set HSA_CLASSIFY_IT=1 (and CLAUDE_API_KEY) to run the live integration test")
}
key := os.Getenv("CLAUDE_API_KEY")
if key == "" {
t.Skip("CLAUDE_API_KEY not set")
}
persons, categories := testCatalog()
c := New(key, "claude-haiku-4-5-20251001", persons, categories)
img, err := os.ReadFile(os.Getenv("HSA_CLASSIFY_IMG"))
if err != nil {
t.Skipf("set HSA_CLASSIFY_IMG to a receipt image: %v", err)
}
got, err := c.Classify(context.Background(), time.Now(), img, "image/jpeg", nil)
if err != nil {
t.Fatalf("Classify: %v", err)
}
t.Logf("suggestion: %+v", got)
}