hsa-app/internal/storage/query_test.go
Jean-Michel Tremblay 5c21f131fd Add AI auto-fill, tally, recent views, and cheaper-by-default classification
Features (see spec.md v2):
- Wire receipt classification into the upload flow; cheap model (Haiku 4.5)
  is now the default, shown as a footnote with per-scan cost in cents.
- Skip-AI toggle to enter fields by hand.
- Duplicate-transaction warning: live check on date+amount, gated submit.
- Tally tab: person x year totals with margins and grand total.
- Recent uploads / recent receipts tabs with paging and file serving.
- People reconcile on startup: merge stray partial names (e.g. "Jude" ->
  "Jude Tremblay"), reassigning receipts; idempotent seeding.
- scripts/build.sh builds the binary; scripts/run.sh builds and runs with .env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:04:42 -04:00

194 lines
5.5 KiB
Go

package storage
import (
"testing"
"time"
)
// insertOn inserts a receipt with the given id, person, category, amount and
// receipt date (uploaded_at defaults to now).
func insertOn(t *testing.T, s *Store, id string, categoryID int64, personID *int64, amountCents int64, date time.Time) {
t.Helper()
r := sampleReceipt(categoryID)
r.ID = id
r.PersonID = personID
r.AmountCents = amountCents
r.ReceiptDate = date
if err := s.Insert(r); err != nil {
t.Fatalf("Insert %s: %v", id, err)
}
}
func TestFindDuplicates_MatchesDateAndAmount(t *testing.T) {
s := newTestStore(t)
cid := firstCategoryID(t, s)
d := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
insertOn(t, s, "a", cid, nil, 4250, d)
insertOn(t, s, "b", cid, nil, 4250, d.AddDate(0, 0, 1)) // different date
insertOn(t, s, "c", cid, nil, 9999, d) // different amount
matches, err := s.FindDuplicates(d, 4250)
if err != nil {
t.Fatal(err)
}
if len(matches) != 1 || matches[0].ID != "a" {
t.Fatalf("matches = %+v, want only id a", matches)
}
}
func TestFindDuplicates_ExcludesSoftDeleted(t *testing.T) {
s := newTestStore(t)
cid := firstCategoryID(t, s)
d := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
insertOn(t, s, "a", cid, nil, 4250, d)
if err := s.SoftDelete("a"); err != nil {
t.Fatal(err)
}
matches, err := s.FindDuplicates(d, 4250)
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("matches = %+v, want none (soft-deleted)", matches)
}
}
func TestListRecent_OrderAndPaging(t *testing.T) {
s := newTestStore(t)
cid := firstCategoryID(t, s)
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
// Insert 12 receipts with ascending receipt dates id00..id11.
for i := 0; i < 12; i++ {
insertOn(t, s, padID(i), cid, nil, int64(100+i), base.AddDate(0, 0, i))
}
rows, hasMore, err := s.ListRecent("receipt_date", 10, 0)
if err != nil {
t.Fatal(err)
}
if len(rows) != 10 || !hasMore {
t.Fatalf("page1 len=%d hasMore=%v, want 10,true", len(rows), hasMore)
}
// Newest receipt date first → id11.
if rows[0].ID != padID(11) {
t.Errorf("first row = %s, want %s", rows[0].ID, padID(11))
}
rows2, hasMore2, err := s.ListRecent("receipt_date", 10, 10)
if err != nil {
t.Fatal(err)
}
if len(rows2) != 2 || hasMore2 {
t.Fatalf("page2 len=%d hasMore=%v, want 2,false", len(rows2), hasMore2)
}
}
func TestListRecent_RejectsBadColumn(t *testing.T) {
s := newTestStore(t)
if _, _, err := s.ListRecent("amount_cents; DROP TABLE receipts", 10, 0); err == nil {
t.Error("expected error for non-whitelisted column")
}
}
func TestTally_MatrixAndMargins(t *testing.T) {
s := newTestStore(t)
cid := firstCategoryID(t, s)
alice, _ := s.AddPerson("Alice")
bob, _ := s.AddPerson("Bob")
y2025 := time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC)
y2026 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
insertOn(t, s, "1", cid, &alice, 1000, y2025)
insertOn(t, s, "2", cid, &alice, 2000, y2026)
insertOn(t, s, "3", cid, &bob, 500, y2026)
insertOn(t, s, "4", cid, nil, 700, y2026) // unassigned
tal, err := s.Tally()
if err != nil {
t.Fatal(err)
}
if len(tal.Years) != 2 || tal.Years[0] != 2025 || tal.Years[1] != 2026 {
t.Fatalf("years = %v, want [2025 2026]", tal.Years)
}
if tal.Grand != 4200 {
t.Errorf("grand = %d, want 4200", tal.Grand)
}
if tal.YearTotals[2026] != 3200 {
t.Errorf("2026 total = %d, want 3200", tal.YearTotals[2026])
}
// Unassigned must sort last.
last := tal.Rows[len(tal.Rows)-1]
if last.Person != "Unassigned" || last.Total != 700 {
t.Errorf("last row = %+v, want Unassigned/700", last)
}
// Alice's per-year cells.
var aliceRow TallyRow
for _, r := range tal.Rows {
if r.Person == "Alice" {
aliceRow = r
}
}
if aliceRow.ByYear[2025] != 1000 || aliceRow.ByYear[2026] != 2000 || aliceRow.Total != 3000 {
t.Errorf("alice row = %+v", aliceRow)
}
}
func TestReconcilePeople_MergesStrayFirstName(t *testing.T) {
s := newTestStore(t)
cid := firstCategoryID(t, s)
// Canonical seeded person + a stray first-name-only duplicate.
if err := s.Seed(nil, []string{"Jude Tremblay"}); err != nil {
t.Fatal(err)
}
stray, err := s.AddPerson("Jude")
if err != nil {
t.Fatal(err)
}
insertOn(t, s, "r1", cid, &stray, 1000, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
merged, err := s.ReconcilePeople([]string{"Jude Tremblay"})
if err != nil {
t.Fatal(err)
}
if merged != 1 {
t.Fatalf("merged = %d, want 1", merged)
}
// Stray gone; only the canonical person remains.
people, _ := s.ListPeople()
if len(people) != 1 || people[0].Label != "Jude Tremblay" {
t.Fatalf("people = %+v, want only Jude Tremblay", people)
}
// The receipt was reassigned to the canonical person (not orphaned).
got, _ := s.Get("r1")
if got.PersonID == nil || *got.PersonID != people[0].ID {
t.Errorf("receipt person = %v, want %d", got.PersonID, people[0].ID)
}
}
func TestReconcilePeople_LeavesCustomAndCanonicalAlone(t *testing.T) {
s := newTestStore(t)
if err := s.Seed(nil, []string{"Jude Tremblay"}); err != nil {
t.Fatal(err)
}
if _, err := s.AddPerson("Grandma"); err != nil { // no canonical first-name match
t.Fatal(err)
}
merged, err := s.ReconcilePeople([]string{"Jude Tremblay"})
if err != nil {
t.Fatal(err)
}
if merged != 0 {
t.Fatalf("merged = %d, want 0", merged)
}
people, _ := s.ListPeople()
if len(people) != 2 {
t.Errorf("people = %+v, want 2 (canonical + custom kept)", people)
}
}
// padID returns a deterministic 36-char UUID-ish id from an index.
func padID(i int) string {
const base = "00000000-0000-0000-0000-0000000000"
return base + string(rune('0'+i/10)) + string(rune('0'+i%10))
}