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