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) }