init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user