This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
package analytics
import (
"context"
"os"
"path/filepath"
"reflect"
"testing"
"finance-duck/internal/domain"
)
func fixture() domain.Dataset {
data := domain.NewDataset()
data.Accounts = []domain.Account{
{ID: "acc_eur", DisplayName: "Current", Currency: "EUR", Active: true},
{ID: "acc_savings", DisplayName: "Savings", Currency: "EUR", Active: true},
{ID: "acc_usd", DisplayName: "Dollar", Currency: "USD", Active: true},
}
data.Categories = append(data.Categories,
domain.Category{ID: "cat_living", Name: "Living", ParentID: "cat_expenses", Kind: "expense"},
domain.Category{ID: "cat_food", Name: "Food", ParentID: "cat_living", Kind: "expense"},
)
data.Tags = []domain.Tag{{ID: "tag_shared", Name: "Shared"}, {ID: "tag_work", Name: "Work"}}
data.Merchants = []domain.Merchant{{ID: "mer_shop", Name: "Shop"}}
add := func(id, account, date, amount, currency, kind, category string, tags ...string) {
merchant := "mer_shop"
if kind == "transfer" {
merchant = ""
}
data.Transactions = append(data.Transactions, domain.Transaction{
Facts: domain.Facts{ID: id, Source: "csv", AccountID: account, BookingDate: date, Amount: domain.Money(amount), Currency: currency, RawDescription: id, Fingerprint: "fp_" + id},
Enrichment: domain.Enrichment{Kind: kind, CategoryID: category, MerchantID: merchant, TagIDs: tags},
})
}
add("tx_large", "acc_eur", "2026-02-10", "-900719925474.0991", "EUR", "expense", "cat_food", "tag_shared", "tag_work")
add("tx_small", "acc_eur", "2026-02-11", "-0.0009", "EUR", "expense", "cat_food", "tag_work")
add("tx_salary", "acc_eur", "2026-02-12", "100.1234", "EUR", "income", domain.IncomeFallback)
add("tx_refund", "acc_eur", "2026-02-13", "0.0001", "EUR", "expense", "cat_food")
add("tx_usd", "acc_usd", "2026-02-10", "-4.2500", "USD", "expense", "cat_food", "tag_shared")
add("tx_previous", "acc_eur", "2026-01-15", "-25.0000", "EUR", "expense", "cat_food")
add("tx_out", "acc_eur", "2026-02-14", "-500.0000", "EUR", "transfer", "")
add("tx_in", "acc_savings", "2026-02-14", "500.0000", "EUR", "transfer", "")
data.Transactions[len(data.Transactions)-2].Enrichment.TransferPeerID = "tx_in"
data.Transactions[len(data.Transactions)-1].Enrichment.TransferPeerID = "tx_out"
return data
}
func openFixture(t *testing.T, data domain.Dataset) *Store {
t.Helper()
s, err := Open("")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := s.Close(); err != nil {
t.Error(err)
}
})
if err := s.Rebuild(context.Background(), data); err != nil {
t.Fatal(err)
}
return s
}
func queryFixture(t *testing.T, s *Store, filter Filter) Dashboard {
t.Helper()
result, err := s.Query(context.Background(), filter)
if err != nil {
t.Fatal(err)
}
return result
}
func TestExactTotalsCurrenciesAndTransferExclusion(t *testing.T) {
s := openFixture(t, fixture())
filter := Filter{From: "2026-02-01", To: "2026-02-28"}
got := queryFixture(t, s, filter)
want := []Total{
{Currency: "EUR", Expenses: "900719925474.1000", Income: "100.1235", Net: "-900719925373.9765"},
{Currency: "USD", Expenses: "4.2500", Income: "0.0000", Net: "-4.2500"},
}
if !reflect.DeepEqual(got.Totals, want) {
t.Fatalf("totals: got %#v, want %#v", got.Totals, want)
}
previous := []Total{{Currency: "EUR", Expenses: "25.0000", Income: "0.0000", Net: "-25.0000"}}
if !reflect.DeepEqual(got.Previous, previous) {
t.Fatalf("previous: got %#v, want %#v", got.Previous, previous)
}
filter.AccountID = "acc_eur"
if totals := queryFixture(t, s, filter).Totals; !reflect.DeepEqual(totals, want[:1]) {
t.Fatalf("one transfer side must not affect totals: %#v", totals)
}
filter.AccountID = "acc_savings"
if totals := queryFixture(t, s, filter).Totals; len(totals) != 0 {
t.Fatalf("transfer-only account must have no spending: %#v", totals)
}
}
func TestTagUnionNeverDuplicatesTransactions(t *testing.T) {
s := openFixture(t, fixture())
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagID: "tag_shared,tag_work,tag_shared"}
got := queryFixture(t, s, filter)
want := []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "0.0000", Net: "-900719925474.1000"}}
if !reflect.DeepEqual(got.Totals, want) {
t.Fatalf("tag union multiplied or dropped spending: %#v", got.Totals)
}
if len(got.Monthly) != 1 || got.Monthly[0].Count != 2 {
t.Fatalf("tag union count: %#v", got.Monthly)
}
filter.TagID = "tag_shared"
got = queryFixture(t, s, filter)
if len(got.Totals) != 1 || got.Totals[0].Expenses != "900719925474.0991" {
t.Fatalf("single tag filter: %#v", got.Totals)
}
filter.TagID = "tag_shared') OR TRUE --"
if totals := queryFixture(t, s, filter).Totals; len(totals) != 0 {
t.Fatalf("tag input altered SQL predicate: %#v", totals)
}
}
func TestAncestorFilteringAndRollups(t *testing.T) {
s := openFixture(t, fixture())
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", CategoryID: "cat_living"}
got := queryFixture(t, s, filter)
want := "-900719925474.0999"
if len(got.Totals) != 1 || got.Totals[0].Net != want {
t.Fatalf("ancestor did not include descendants: %#v", got.Totals)
}
groups := map[string]Group{}
for _, group := range got.Categories {
groups[group.ID] = group
}
for _, id := range []string{"cat_food", "cat_living", "cat_expenses"} {
group, ok := groups[id]
if !ok || group.Amount != want || group.Count != 3 {
t.Fatalf("ancestor %s: %#v", id, group)
}
}
if len(groups) != 3 {
t.Fatalf("unrelated category included: %#v", got.Categories)
}
}
func TestDeletedIndexRebuildIsIdentical(t *testing.T) {
data := fixture()
path := filepath.Join(t.TempDir(), "analytics.duckdb")
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := s.Rebuild(context.Background(), data); err != nil {
s.Close()
t.Fatal(err)
}
before := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28"})
if err := s.Close(); err != nil {
t.Fatal(err)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
s, err = Open(path)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.Rebuild(context.Background(), data); err != nil {
t.Fatal(err)
}
after := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28"})
if !reflect.DeepEqual(before, after) {
t.Fatalf("rebuilding a deleted index changed dashboard:\nbefore %#v\nafter %#v", before, after)
}
if err := s.Rebuild(context.Background(), data); err != nil {
t.Fatal(err)
}
again := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28"})
if !reflect.DeepEqual(after, again) {
t.Fatalf("repeat rebuild changed dashboard: %#v", again)
}
}
func TestPostingsBalancePerTransactionAndTransferClearing(t *testing.T) {
s := openFixture(t, fixture())
rows, err := s.db.Query(`SELECT transaction_id FROM postings GROUP BY transaction_id, currency HAVING COUNT(*) <> 2 OR SUM(amount) <> 0`)
if err != nil {
t.Fatal(err)
}
if rows.Next() {
rows.Close()
t.Fatal("unbalanced transaction postings")
}
if err := rows.Err(); err != nil {
rows.Close()
t.Fatal(err)
}
rows.Close()
var amount string
if err := s.db.QueryRow(`SELECT CAST(SUM(amount) AS VARCHAR) FROM postings WHERE ledger_account = 'clearing:transfers'`).Scan(&amount); err != nil {
t.Fatal(err)
}
if amount != "0.0000" {
t.Fatalf("transfer clearing did not cancel: %s", amount)
}
if err := s.db.QueryRow(`SELECT CAST(SUM(amount) AS VARCHAR) FROM postings WHERE ledger_account = 'asset:acc_savings'`).Scan(&amount); err != nil {
t.Fatal(err)
}
if amount != "500.0000" {
t.Fatalf("transfer asset posting lost: %s", amount)
}
}
func TestRejectedRebuildPreservesPublishedDashboard(t *testing.T) {
data := fixture()
s := openFixture(t, data)
before := queryFixture(t, s, Filter{})
invalid := domain.Clone(data)
invalid.Transactions[0].Facts.Amount = "invalid"
if err := s.Rebuild(context.Background(), invalid); err == nil {
t.Fatal("accepted invalid money")
}
if after := queryFixture(t, s, Filter{}); !reflect.DeepEqual(before, after) {
t.Fatal("failed rebuild replaced previous index")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := s.Rebuild(ctx, data); err == nil {
t.Fatal("cancelled rebuild succeeded")
}
if after := queryFixture(t, s, Filter{}); !reflect.DeepEqual(before, after) {
t.Fatal("cancelled rebuild replaced previous index")
}
}
func TestRecurringRequiresStableCadenceAndSeparatesCurrencies(t *testing.T) {
data := fixture()
data.Transactions = nil
for i, date := range []string{"2026-01-15", "2026-02-15", "2026-03-15"} {
for _, account := range []struct{ id, currency string }{{"acc_eur", "EUR"}, {"acc_usd", "USD"}} {
id := account.id + "_subscription_" + string(rune('a'+i))
data.Transactions = append(data.Transactions, domain.Transaction{
Facts: domain.Facts{ID: id, Source: "csv", AccountID: account.id, BookingDate: date, Amount: "-12.3400", Currency: account.currency, RawDescription: "Subscription", Fingerprint: id},
Enrichment: domain.Enrichment{Kind: "expense", CategoryID: "cat_food", MerchantID: "mer_shop", TagIDs: []string{}},
})
}
}
s := openFixture(t, data)
got := queryFixture(t, s, Filter{})
if len(got.Recurring) != 2 {
t.Fatalf("expected separate currency streams: %#v", got.Recurring)
}
for _, group := range got.Recurring {
if group.Amount != "-37.0200" || group.Count != 3 || group.Period != "monthly" {
t.Fatalf("recurring payment: %#v", group)
}
}
// One out-of-cadence payment invalidates the apparent regular schedule.
data.Transactions[4].Facts.BookingDate = "2026-06-15"
if err := s.Rebuild(context.Background(), data); err != nil {
t.Fatal(err)
}
got = queryFixture(t, s, Filter{})
if len(got.Recurring) != 1 || got.Recurring[0].Currency != "USD" {
t.Fatalf("irregular series counted as recurring: %#v", got.Recurring)
}
}
func TestEmptyIndexAndInvalidDates(t *testing.T) {
s := openFixture(t, domain.NewDataset())
got := queryFixture(t, s, Filter{})
if got.Totals == nil || got.Previous == nil || got.Monthly == nil || got.Categories == nil || got.Tags == nil || got.Merchants == nil || got.Accounts == nil || got.Recurring == nil {
t.Fatal("empty collections must encode as arrays")
}
for _, filter := range []Filter{{From: "2026-02-30"}, {From: "2026-03-01", To: "2026-02-01"}} {
if _, err := s.Query(context.Background(), filter); err == nil {
t.Fatalf("accepted invalid date filter: %#v", filter)
}
}
}