195 lines
5.5 KiB
Go
195 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))
|
||
|
|
}
|