166 lines
7.3 KiB
Go
166 lines
7.3 KiB
Go
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" }},
|
|
}
|
|
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)
|
|
}
|
|
}
|