hsa-app/internal/storage/query_test.go
Jean-Michel Tremblay 04d6e8177d Add receipt attachments captured at upload time (spec item 10)
- New attachments table (FK to receipts), dual-write: file on disk + DB blob.
- On-disk layout split: receipts under <ROOT>/receipts/<YYYY>/..., attachments
  under <ROOT>/attachments/<YYYY>/..., attachments named from the parent
  receipt's date+amount stem (_att, _att_1, ...).
- Upload form gains an optional multi-file "Additional files" field; the files
  ride along with POST /upload, saved after the receipt row exists. No AI runs
  on attachments; primary-image auto-fill unchanged.
- GET /attachment/{id}/file serves blobs; confirm page lists them; recent list
  links them. Adding attachments to an already-saved receipt is not yet supported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:06:15 -04:00

242 lines
7 KiB
Go

package storage
import (
"testing"
"time"
"maisym.com/hsa/internal/receipt"
)
// 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)
}
}
func TestAttachments_InsertGetList(t *testing.T) {
s := newTestStore(t)
cid := firstCategoryID(t, s)
insertOn(t, s, "rcpt", cid, nil, 1000, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
att := receipt.Attachment{
ID: "att1",
ReceiptID: "rcpt",
UploadedBy: "jm@example.com",
UploadedAt: time.Now().UTC().Truncate(time.Second),
FilePath: "attachments/2026/06_01_10.00_att.png",
ImageData: []byte("\x89PNGblob"),
FileSizeBytes: 8,
OriginalFilename: "page1.png",
MimeType: "image/png",
}
if err := s.InsertAttachment(att); err != nil {
t.Fatalf("InsertAttachment: %v", err)
}
got, err := s.GetAttachment("att1")
if err != nil {
t.Fatal(err)
}
if got.ReceiptID != "rcpt" || string(got.ImageData) != "\x89PNGblob" || got.OriginalFilename != "page1.png" {
t.Errorf("GetAttachment mismatch: %+v", got)
}
metas, err := s.ListAttachmentMeta("rcpt")
if err != nil {
t.Fatal(err)
}
if len(metas) != 1 || metas[0].ID != "att1" || metas[0].OriginalFilename != "page1.png" {
t.Errorf("ListAttachmentMeta = %+v", metas)
}
}
func TestInsertAttachment_RejectsUnknownReceiptFK(t *testing.T) {
s := newTestStore(t)
att := receipt.Attachment{ID: "x", ReceiptID: "nope", UploadedAt: time.Now(),
FilePath: "p", ImageData: []byte("b"), OriginalFilename: "f", MimeType: "image/png"}
if err := s.InsertAttachment(att); err == nil {
t.Error("expected FK violation for unknown receipt_id")
}
}
// 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))
}