Files
email_agent/internal/classify/jev_test.go
Sam Rolfe af3b5e5f0e Phase 3: JEV classification, rules table, editable categories (AUD-69)
Implements FR-3, FR-4, FR-5 and FR-14.

- internal/classify: TypeSafe System One (JEV) HTTP client with the confirmed
  request/response shape, retry with backoff on 429/529/5xx, a plain-text rule
  engine (first match wins, overrides only the named fields), and bounded
  orchestration with a per-run cap.
- internal/store: migration v3 adds an editable `categories` table (name =
  JEV Choice option and classifications key; rename cascades, remove reassigns)
  and seeds a starter rules version once. Classification persistence, coverage,
  and pending/all message queries.
- internal/server: POST /actions/classify and /actions/reclassify, plus the
  FR-14 category editor (GET /categories and add/rename/remove). The triage list
  now groups by the live category list and shows action + confidence.
- CLI: -classify and -classify-all for n8n.
- Docs: docs/phase3-classify.md with the confirmed JEV shape, a live read-only
  classification, and the DoD evidence.

DoD: all 100 stored messages have a non-null category, importance, and suggested
action (0 nulls); JEV picked a newly added/renamed category for 6 real messages
over HTTP. One live JEV call recorded in the phase doc.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-11 21:37:38 +11:00

155 lines
5.0 KiB
Go

package classify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestClientClassifyRequestAndResponse(t *testing.T) {
var (
gotPath string
gotAuth string
gotBody map[string]any
callCount int32
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&callCount, 1)
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Errorf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"model": "jev-1.13.0",
"answers": {
"category": {"type":"choice","choice":"Action","confidence":0.81,"probabilities":{"Action":0.81,"Info":0.19}},
"importance": {"type":"choice","choice":"high","confidence":0.9,"probabilities":{"high":0.9}},
"action": {"type":"choice","choice":"reply","confidence":0.7,"probabilities":{"reply":0.7}}
},
"usage": {"input_tokens": 100, "output_tokens": 20}
}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", "jev-latest", nil)
res, err := client.Classify(context.Background(), MessageInput{
From: "favrolfe@gmail.com", To: "hello@where-woof.com",
Subject: "Quick question", Source: "hello@where-woof.com",
Body: "Can you send me the invoice?", Received: "2026-10-11T09:00:00Z",
}, []CategoryOption{
{Name: "Action", Description: "Needs Sam to do something"},
{Name: "Info", Description: "Informational"},
})
if err != nil {
t.Fatalf("Classify() error = %v", err)
}
if gotPath != "/v1/systemone" {
t.Errorf("path = %q, want /v1/systemone", gotPath)
}
if gotAuth != "Bearer test-key" {
t.Errorf("Authorization = %q", gotAuth)
}
if gotBody["model"] != "jev-latest" {
t.Errorf("model = %v, want jev-latest", gotBody["model"])
}
questions, _ := gotBody["questions"].(map[string]any)
if len(questions) != 3 {
t.Fatalf("questions = %d, want 3", len(questions))
}
categoryQ, _ := questions["category"].(map[string]any)
criteria, _ := categoryQ["criteria"].(map[string]any)
if len(criteria) != 2 {
t.Errorf("category criteria = %v, want the live category list", criteria)
}
state, _ := gotBody["state"].(map[string]any)
if _, ok := state["email"]; !ok {
t.Errorf("state = %v, want an email object", state)
}
if res.Category != "Action" || res.Importance != "high" || res.Action != "reply" {
t.Errorf("result = %+v", res)
}
if res.CategoryConfidence != 0.81 {
t.Errorf("CategoryConfidence = %v, want 0.81", res.CategoryConfidence)
}
if res.Model != "jev-1.13.0" {
t.Errorf("Model = %q, want jev-1.13.0", res.Model)
}
if atomic.LoadInt32(&callCount) != 1 {
t.Errorf("call count = %d, want 1", callCount)
}
}
func TestClientRetriesRateLimit(t *testing.T) {
var attempts int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.AddInt32(&attempts, 1) == 1 {
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"error":"rate limited"}`))
return
}
_, _ = w.Write([]byte(`{"model":"jev-1.13.0","answers":{
"category":{"type":"choice","choice":"Info","confidence":1},
"importance":{"type":"choice","choice":"low","confidence":1},
"action":{"type":"choice","choice":"keep","confidence":1}}}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "k", "", nil)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
res, err := client.Classify(ctx, MessageInput{Subject: "hi"}, []CategoryOption{{Name: "Info"}})
if err != nil {
t.Fatalf("Classify() error = %v", err)
}
if res.Category != "Info" {
t.Errorf("Category = %q, want Info", res.Category)
}
if got := atomic.LoadInt32(&attempts); got != 2 {
t.Errorf("attempts = %d, want 2", got)
}
}
func TestClientRequiresAPIKey(t *testing.T) {
client := NewClient("http://example.invalid", "", "", nil)
if client.Configured() {
t.Fatal("Configured() = true without an API key")
}
if _, err := client.Classify(context.Background(), MessageInput{}, []CategoryOption{{Name: "Info"}}); err != ErrNotConfigured {
t.Fatalf("err = %v, want ErrNotConfigured", err)
}
}
func TestClientDefaultsBaseURL(t *testing.T) {
client := NewClient("", "k", "", nil)
if client.baseURL != DefaultBaseURL {
t.Errorf("baseURL = %q, want %q", client.baseURL, DefaultBaseURL)
}
if client.Model() != DefaultModel {
t.Errorf("model = %q, want %q", client.Model(), DefaultModel)
}
}
func TestClientHTTPErrorSurfacesBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid api key"}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "bad", "", nil)
_, err := client.Classify(context.Background(), MessageInput{}, []CategoryOption{{Name: "Info"}})
if err == nil || !strings.Contains(err.Error(), "invalid api key") {
t.Fatalf("err = %v, want the 401 body", err)
}
}