Add persistent multi-tag include and exclude filters
This commit is contained in:
@@ -132,7 +132,7 @@ func TestMonthlySplitsDirectionsAndRanksLargestPerCurrency(t *testing.T) {
|
||||
|
||||
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"}
|
||||
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) {
|
||||
@@ -141,17 +141,132 @@ func TestTagUnionNeverDuplicatesTransactions(t *testing.T) {
|
||||
if len(got.Monthly) != 1 || got.Monthly[0].Count != 2 {
|
||||
t.Fatalf("tag union count: %#v", got.Monthly)
|
||||
}
|
||||
filter.TagID = "tag_shared"
|
||||
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.TagID = "tag_shared') OR TRUE --"
|
||||
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"}
|
||||
|
||||
Reference in New Issue
Block a user