package domain import ( "encoding/json" "math" "strings" "testing" ) func sampleDataset() Dataset { d := NewDataset() d.Accounts = []Account{{ID: "acc_main", DisplayName: "Main", Currency: "EUR", Active: true}, {ID: "acc_save", DisplayName: "Savings", Currency: "EUR", Active: true}} d.Categories = append(d.Categories, Category{ID: "cat_food", Name: "Food", ParentID: "cat_expenses", Kind: "expense"}, Category{ID: "cat_grocery", Name: "Groceries", ParentID: "cat_food", Kind: "expense"}) d.Tags = []Tag{{ID: "tag_shared", Name: "Shared"}} d.Merchants = []Merchant{{ID: "mer_shop", Name: "Shop", Aliases: []string{"Shop GmbH"}, DefaultCategoryID: "cat_grocery", DefaultTagIDs: []string{"tag_shared"}, UseDefaults: true}} f := Facts{ID: "tx_one", Source: "csv", AccountID: "acc_main", BookingDate: "2026-01-01", Amount: "-12.3401", Currency: "EUR", RawDescription: "Shopping", Fingerprint: "fp_one"} d.Transactions = []Transaction{{Facts: f, Enrichment: Fallback(f)}} return d } func TestMoneyExactBoundaries(t *testing.T) { cases := []struct { input, canonical string minor int64 }{{"0", "0.00", 0}, {"-0.0000", "0.00", 0}, {"12.3401", "12.3401", 123401}, {"-0.0001", "-0.0001", -1}, {"922337203685477.5807", "922337203685477.5807", math.MaxInt64}, {"-922337203685477.5808", "-922337203685477.5808", math.MinInt64}} for _, tc := range cases { t.Run(tc.input, func(t *testing.T) { m, err := ParseMoney(tc.input) if err != nil { t.Fatal(err) } if m.String() != tc.canonical { t.Fatalf("got %q, want %q", m.String(), tc.canonical) } n, err := m.Minor() if err != nil || n != tc.minor { t.Fatalf("minor = %d, %v", n, err) } round, err := ParseMoney(m.String()) if err != nil || round != m { t.Fatalf("unstable money: %s, %v", round, err) } }) } for _, s := range []string{"", "+1", " 1", "01", ".1", "1.", "1.00001", "1e2", "NaN", "922337203685477.5808", "-922337203685477.5809", "99999999999999999999999999999999999999", "1,25", "--1"} { t.Run("reject_"+s, func(t *testing.T) { if _, err := ParseMoney(s); err == nil { t.Fatalf("accepted %q", s) } if _, err := Money(s).Minor(); err == nil { t.Fatalf("Minor accepted %q", s) } }) } } func TestDomainRejectsBrokenReferencesAndTaxonomy(t *testing.T) { cases := []struct { name string mutate func(*Dataset) }{ {"cycle", func(d *Dataset) { d.Categories[4].ParentID = "cat_grocery" }}, {"nonleaf assignment", func(d *Dataset) { d.Transactions[0].Enrichment.CategoryID = "cat_food" }}, {"wrong category kind", func(d *Dataset) { d.Transactions[0].Enrichment.CategoryID = IncomeFallback }}, {"remove fallback", func(d *Dataset) { d.Categories = append(d.Categories[:1], d.Categories[2:]...) }}, {"move fallback", func(d *Dataset) { d.Categories[1].ParentID = "cat_food" }}, {"fallback child", func(d *Dataset) { d.Categories[4].ParentID = ExpenseFallback }}, {"missing account", func(d *Dataset) { d.Transactions[0].Facts.AccountID = "acc_missing" }}, {"currency mismatch", func(d *Dataset) { d.Transactions[0].Facts.Currency = "USD" }}, {"invalid date", func(d *Dataset) { d.Transactions[0].Facts.BookingDate = "2026-02-30" }}, {"year zero cannot map to journal", func(d *Dataset) { d.Transactions[0].Facts.BookingDate = "0000-01-01" }}, {"invalid UTF-8 facts", func(d *Dataset) { d.Transactions[0].Facts.RawDescription = string([]byte{0xff}) }}, {"invalid money", func(d *Dataset) { d.Transactions[0].Facts.Amount = "1e2" }}, {"unknown tag", func(d *Dataset) { d.Transactions[0].Enrichment.TagIDs = []string{"tag_missing"} }}, {"duplicate tag", func(d *Dataset) { d.Transactions[0].Enrichment.TagIDs = []string{"tag_shared", "tag_shared"} }}, {"unknown merchant", func(d *Dataset) { d.Transactions[0].Enrichment.MerchantID = "mer_missing" }}, {"duplicate identity", func(d *Dataset) { d.Tags[0].ID = "acc_main" }}, {"invalid provenance date", func(d *Dataset) { d.Transactions[0].Enrichment.Classification.Timestamp = "yesterday" }}, {"nonleaf merchant default", func(d *Dataset) { d.Merchants[0].DefaultCategoryID = "cat_food" }}, {"oversized tag name", func(d *Dataset) { d.Tags[0].Name = strings.Repeat("x", 201) }}, {"oversized category name", func(d *Dataset) { d.Categories[2].Name = strings.Repeat("x", 201) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { d := sampleDataset() tc.mutate(&d) if err := Validate(d); err == nil { t.Fatal("accepted invalid dataset") } }) } d := sampleDataset() if err := Validate(d); err != nil { t.Fatal(err) } if got := CategoryPath(d, "cat_grocery"); got != "Expenses / Food / Groceries" { t.Fatalf("path: %s", got) } } func transferDataset() Dataset { d := sampleDataset() d.Transactions[0].Facts.Amount = "-10.00" peer := d.Transactions[0] peer.Facts.ID = "tx_two" peer.Facts.Fingerprint = "fp_two" peer.Facts.AccountID = "acc_save" peer.Facts.Amount = "10.00" d.Transactions[0].Enrichment = Enrichment{Kind: "transfer", TransferPeerID: "tx_two", TagIDs: []string{}, Classification: Provenance{Source: "manual"}} peer.Enrichment = Enrichment{Kind: "transfer", TransferPeerID: "tx_one", TagIDs: []string{}, Classification: Provenance{Source: "manual"}} d.Transactions = append(d.Transactions, peer) return d } func TestTransferRequiresReciprocalOppositeSameCurrencyAccounts(t *testing.T) { d := transferDataset() if err := Validate(d); err != nil { t.Fatal(err) } cases := []struct { name string mutate func(*Dataset) }{ {"one-sided", func(d *Dataset) { d.Transactions[1].Enrichment = Fallback(d.Transactions[1].Facts) }}, {"self", func(d *Dataset) { d.Transactions[0].Enrichment.TransferPeerID = "tx_one" }}, {"same account", func(d *Dataset) { d.Transactions[1].Facts.AccountID = "acc_main" }}, {"unequal", func(d *Dataset) { d.Transactions[1].Facts.Amount = "10.0001" }}, {"unlike currencies", func(d *Dataset) { d.Accounts[1].Currency = "USD"; d.Transactions[1].Facts.Currency = "USD" }}, {"AI", func(d *Dataset) { d.Transactions[0].Enrichment.Classification.Source = "ai" }}, {"category", func(d *Dataset) { d.Transactions[0].Enrichment.CategoryID = ExpenseFallback }}, {"zero", func(d *Dataset) { d.Transactions[0].Facts.Amount = "0"; d.Transactions[1].Facts.Amount = "0" }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { d := transferDataset() tc.mutate(&d) if err := Validate(d); err == nil { t.Fatal("accepted invalid transfer") } }) } } func TestCloneOwnsNestedListsAndFallback(t *testing.T) { original := sampleDataset() original.Transactions[0].Enrichment.TagIDs = []string{"tag_shared"} copy := Clone(original) copy.Merchants[0].Aliases[0] = "Changed" copy.Merchants[0].DefaultTagIDs[0] = "other" copy.Transactions[0].Enrichment.TagIDs[0] = "other" copy.Categories[0].Name = "Changed" if original.Merchants[0].Aliases[0] != "Shop GmbH" || original.Merchants[0].DefaultTagIDs[0] != "tag_shared" || original.Transactions[0].Enrichment.TagIDs[0] != "tag_shared" || original.Categories[0].Name != "Expenses" { t.Fatal("clone shares mutable storage") } f := original.Transactions[0].Facts f.Amount = "1.00" if e := Fallback(f); e.Kind != "income" || e.CategoryID != IncomeFallback { t.Fatalf("income fallback: %#v", e) } f.Amount = "-1.00" if e := Fallback(f); e.Kind != "expense" || e.CategoryID != ExpenseFallback { t.Fatalf("expense fallback: %#v", e) } empty := Clone(Dataset{}) raw, err := json.Marshal(empty) if err != nil { t.Fatal(err) } if strings.Contains(string(raw), "null") { t.Fatalf("nil public lists: %s", raw) } }