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