Go app for capturing and archiving HSA-eligible receipts: OIDC/PKCE auth against Authelia, SQLite storage with dual-write (filesystem + DB blob), mobile-first upload, and DB export. Adds AI receipt classification: a config.json catalog of people and categories (seeded into the DB on startup), a prompt builder that derives name-order/initial variants from the data (with same-surname ambiguity handling), and an Anthropic tool-use client behind POST /classify. Tests run against a mock endpoint; a live integration test is env-gated to the cheapest model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
4.4 KiB
Go
156 lines
4.4 KiB
Go
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")
|
|
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")
|
|
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")
|
|
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")
|
|
if err != nil {
|
|
t.Fatalf("Classify: %v", err)
|
|
}
|
|
t.Logf("suggestion: %+v", got)
|
|
}
|