Files
finance-duck/internal/domain/domain_test.go
T
2026-09-10 12:30:42 +02:00

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)
}
}