All checks were successful
Build and Test / build-and-test (push) Successful in 37s
Add a global, temporal ai_notes list appended to the classifier prompt (seeded once from no-PII defaults, documented in README), managed inline on a new AI tab with a read-only view of the assembled prompt. Every AI-run upload records the browser-round-tripped suggestion blob + model; misreads are derived (final field != AI guess) and reviewed one by one (image + per-field guess-vs-entered + notes-since), attributing which note fixed each or closing unresolved. Update SPEC (new section 10), DESIGN item 15, README, and changelog (0.0.3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
122 lines
3.9 KiB
Go
122 lines
3.9 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// uploadWithClassifyBlob posts a receipt plus a round-tripped AI suggestion blob.
|
|
func uploadWithClassifyBlob(t *testing.T, s *Server, amount, classifyJSON string) {
|
|
t.Helper()
|
|
body, ct := multipartUpload(t, map[string]string{
|
|
"amount": amount,
|
|
"receipt_date": "2026-06-01",
|
|
"category_id": aCategoryID(t, s),
|
|
"classify_json": classifyJSON,
|
|
}, "receipt", "r.png", fakePNG())
|
|
req := httptest.NewRequest(http.MethodPost, "/upload", body)
|
|
req.Header.Set("Content-Type", ct)
|
|
req.AddCookie(authCookie(t, s))
|
|
rec := httptest.NewRecorder()
|
|
s.Routes().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("upload status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAI_MissRecordedAndReviewable(t *testing.T) {
|
|
s := testServerWithStore(t)
|
|
|
|
// AI suggested $99.99; user submitted $12.34 → an amount miss.
|
|
blob := `{"amount":"99.99","date":"2026-06-01","category_id":null,"person_id":null,"model":"haiku-test"}`
|
|
uploadWithClassifyBlob(t, s, "12.34", blob)
|
|
|
|
// Stored classification exists.
|
|
rows, err := s.store.ListUnreviewedClassifications()
|
|
if err != nil || len(rows) != 1 {
|
|
t.Fatalf("ListUnreviewedClassifications: err=%v len=%d", err, len(rows))
|
|
}
|
|
id := rows[0].ReceiptID
|
|
|
|
// /ai lists it as a miss touching "amount".
|
|
page := get(t, s, "/ai").Body.String()
|
|
if !strings.Contains(page, "haiku-test") || !strings.Contains(page, "amount") {
|
|
t.Errorf("/ai missing the amount miss: %s", page)
|
|
}
|
|
|
|
// Review page shows the AI guess vs the entered value.
|
|
rv := get(t, s, "/ai/review/"+id).Body.String()
|
|
if !strings.Contains(rv, "99.99") || !strings.Contains(rv, "12.34") {
|
|
t.Errorf("review page missing guess/final: %s", rv)
|
|
}
|
|
|
|
// Submitting the review (no fix ticked) closes it as unresolved and clears the queue.
|
|
req := httptest.NewRequest(http.MethodPost, "/ai/review/"+id, strings.NewReader(url.Values{}.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.AddCookie(authCookie(t, s))
|
|
rec := httptest.NewRecorder()
|
|
s.Routes().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("review submit status=%d", rec.Code)
|
|
}
|
|
left, _ := s.store.ListUnreviewedClassifications()
|
|
if len(left) != 0 {
|
|
t.Errorf("queue not cleared after review: %d left", len(left))
|
|
}
|
|
if c, _ := s.store.GetClassification(id); c.Resolution != "unresolved" {
|
|
t.Errorf("resolution = %q, want unresolved", c.Resolution)
|
|
}
|
|
}
|
|
|
|
func TestAI_NoMissWhenSuggestionMatches(t *testing.T) {
|
|
s := testServerWithStore(t)
|
|
cat := aCategoryID(t, s)
|
|
// AI nailed every field the user submitted → not a miss.
|
|
blob := `{"amount":"12.34","date":"2026-06-01","category_id":` + cat + `,"person_id":null,"model":"m"}`
|
|
uploadWithClassifyBlob(t, s, "12.34", blob)
|
|
|
|
page := get(t, s, "/ai").Body.String()
|
|
if !strings.Contains(page, "No misreads to review") {
|
|
t.Errorf("expected no-misreads message, got: %s", page)
|
|
}
|
|
}
|
|
|
|
func TestAI_NotesCRUD(t *testing.T) {
|
|
s := testServerWithStore(t)
|
|
|
|
post := func(path string, form url.Values) {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.AddCookie(authCookie(t, s))
|
|
rec := httptest.NewRecorder()
|
|
s.Routes().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("%s status=%d", path, rec.Code)
|
|
}
|
|
}
|
|
|
|
post("/ai/notes", url.Values{"text": {"European dates are DD/MM"}})
|
|
notes, _ := s.store.ListActiveNotes()
|
|
var added int64
|
|
found := false
|
|
for _, n := range notes {
|
|
if n.Text == "European dates are DD/MM" {
|
|
added, found = n.ID, true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("note not added")
|
|
}
|
|
|
|
post("/ai/notes/delete", url.Values{"id": {strconv.FormatInt(added, 10)}})
|
|
notes, _ = s.store.ListActiveNotes()
|
|
for _, n := range notes {
|
|
if n.ID == added {
|
|
t.Error("note not deleted")
|
|
}
|
|
}
|
|
}
|