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