Files
finance-duck/internal/analytics/store_test.go
T

430 lines
16 KiB
Go

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 TestMonthlySplitsDirectionsAndRanksLargestPerCurrency(t *testing.T) {
s := openFixture(t, fixture())
got := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28"})
months := []MonthlyPoint{
{Period: "2026-02", Currency: "EUR", Income: "100.1235", Expenses: "900719925474.1000", Net: "-900719925373.9765", Count: 4},
{Period: "2026-02", Currency: "USD", Income: "0.0000", Expenses: "4.2500", Net: "-4.2500", Count: 1},
}
if !reflect.DeepEqual(got.Monthly, months) {
t.Fatalf("monthly: got %#v, want %#v", got.Monthly, months)
}
// A repeat payee contributes only its biggest payment, and one currency's
// outflows never crowd another currency out of the list.
largest := []Group{
{ID: "tx_large", Name: "Shop", Currency: "EUR", Period: "2026-02-10", Amount: "-900719925474.0991", Count: 1},
{ID: "tx_usd", Name: "Shop", Currency: "USD", Period: "2026-02-10", Amount: "-4.2500", Count: 1},
}
if !reflect.DeepEqual(got.Largest, largest) {
t.Fatalf("largest: got %#v, want %#v", got.Largest, largest)
}
// The comparison period rolls up through the same ancestors as the current one.
previous := []Group{
{ID: "cat_expenses", Name: "Expenses", Currency: "EUR", Amount: "-25.0000", Count: 1},
{ID: "cat_food", Name: "Food", Currency: "EUR", Amount: "-25.0000", Count: 1},
{ID: "cat_living", Name: "Living", Currency: "EUR", Amount: "-25.0000", Count: 1},
}
if !reflect.DeepEqual(got.PreviousCategories, previous) {
t.Fatalf("previous categories: got %#v, want %#v", got.PreviousCategories, previous)
}
if all := queryFixture(t, s, Filter{}); len(all.PreviousCategories) != 0 {
t.Fatalf("all-time query must have no comparison period: %#v", all.PreviousCategories)
}
}
func TestTagUnionNeverDuplicatesTransactions(t *testing.T) {
s := openFixture(t, fixture())
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagIDs: []string{"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.TagIDs = []string{"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.TagIDs = []string{"tag_shared') OR TRUE --"}
if totals := queryFixture(t, s, filter).Totals; len(totals) != 0 {
t.Fatalf("tag input altered SQL predicate: %#v", totals)
}
}
func TestTagExclusionsAndComposition(t *testing.T) {
s := openFixture(t, fixture())
cases := []struct {
name string
include []string
exclude []string
totals []Total
count int64
tagIDs []string
}{
{
name: "excluded tag removes whole multi-tag transaction",
exclude: []string{"tag_shared"},
totals: []Total{{Currency: "EUR", Expenses: "0.0009", Income: "100.1235", Net: "100.1226"}},
count: 3,
tagIDs: []string{"tag_work"},
},
{
name: "any excluded tag removes transaction and untagged income survives",
exclude: []string{"tag_shared", "tag_work"},
totals: []Total{{Currency: "EUR", Expenses: "0.0000", Income: "100.1235", Net: "100.1235"}},
count: 2,
tagIDs: []string{},
},
{
name: "include union and exclusion intersect with exclusion winning overlap",
include: []string{"tag_shared", "tag_work"},
exclude: []string{"tag_shared"},
totals: []Total{{Currency: "EUR", Expenses: "0.0009", Income: "0.0000", Net: "-0.0009"}},
count: 1,
tagIDs: []string{"tag_work"},
},
{
name: "identical include and exclude match nothing",
include: []string{"tag_shared"},
exclude: []string{"tag_shared"},
totals: []Total{},
tagIDs: []string{},
},
{
name: "exclusion values cannot alter SQL",
exclude: []string{"tag_shared') OR TRUE --"},
totals: []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "100.1235", Net: "-900719925373.9765"}},
count: 4,
tagIDs: []string{"tag_shared", "tag_work"},
},
{
name: "empty lists leave transactions unrestricted",
include: []string{},
exclude: []string{},
totals: []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "100.1235", Net: "-900719925373.9765"}},
count: 4,
tagIDs: []string{"tag_shared", "tag_work"},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
got := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagIDs: tt.include, ExcludeTagIDs: tt.exclude})
if !reflect.DeepEqual(got.Totals, tt.totals) {
t.Fatalf("totals: got %#v, want %#v", got.Totals, tt.totals)
}
var count int64
for _, month := range got.Monthly {
count += month.Count
}
if count != tt.count {
t.Fatalf("transaction count: got %d, want %d", count, tt.count)
}
tagIDs := make([]string, 0, len(got.Tags))
for _, tag := range got.Tags {
tagIDs = append(tagIDs, tag.ID)
}
if !reflect.DeepEqual(tagIDs, tt.tagIDs) {
t.Fatalf("tag groups: got %#v, want %#v", got.Tags, tt.tagIDs)
}
accounts := []Group{}
if len(tt.totals) != 0 {
accounts = append(accounts, Group{ID: "acc_eur", Name: "Current", Currency: "EUR", Amount: tt.totals[0].Net, Count: tt.count})
}
if !reflect.DeepEqual(got.Accounts, accounts) {
t.Fatalf("account groups: got %#v, want %#v", got.Accounts, accounts)
}
})
}
}
func TestTagFiltersApplyToPreviousPeriodAndCategoryRollups(t *testing.T) {
data := fixture()
// Mirror current transactions into the preceding month, including the
// untagged income and the multi-tag expense that must be excluded.
for _, transaction := range data.Transactions[:4] {
transaction.Facts.ID += "_previous"
transaction.Facts.Fingerprint += "_previous"
transaction.Facts.BookingDate = "2026-01-15"
data.Transactions = append(data.Transactions, transaction)
}
s := openFixture(t, data)
got := queryFixture(t, s, Filter{
From: "2026-02-01", To: "2026-02-28", Currency: "EUR",
TagIDs: []string{"tag_shared", "tag_work"}, ExcludeTagIDs: []string{"tag_shared"},
})
want := []Total{{Currency: "EUR", Expenses: "0.0009", Income: "0.0000", Net: "-0.0009"}}
if !reflect.DeepEqual(got.Totals, want) || !reflect.DeepEqual(got.Previous, want) {
t.Fatalf("period totals: current %#v, previous %#v, want %#v", got.Totals, got.Previous, want)
}
groups := []Group{
{ID: "cat_expenses", Name: "Expenses", Currency: "EUR", Amount: "-0.0009", Count: 1},
{ID: "cat_food", Name: "Food", Currency: "EUR", Amount: "-0.0009", Count: 1},
{ID: "cat_living", Name: "Living", Currency: "EUR", Amount: "-0.0009", Count: 1},
}
if !reflect.DeepEqual(got.Categories, groups) || !reflect.DeepEqual(got.PreviousCategories, groups) {
t.Fatalf("category rollups: current %#v, previous %#v, want %#v", got.Categories, got.PreviousCategories, groups)
}
}
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.PreviousCategories == nil ||
got.Tags == nil || got.Merchants == nil || got.Accounts == nil || got.Recurring == nil || got.Largest == 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)
}
}
}