Add persistent multi-tag include and exclude filters
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
@@ -11,12 +12,15 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"finance-duck/internal/analytics"
|
||||
"finance-duck/internal/app"
|
||||
"finance-duck/internal/banking"
|
||||
"finance-duck/internal/domain"
|
||||
)
|
||||
|
||||
func TestOriginAndHostGuardProtectNoLoginService(t *testing.T) {
|
||||
@@ -415,3 +419,99 @@ func TestCSVImportOverHTTPImportsOnlyAfterConfirmation(t *testing.T) {
|
||||
}
|
||||
send("/api/import/confirm", "application/json", confirm, origin, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestDashboardRepeatedTagFiltersOverHTTP(t *testing.T) {
|
||||
t.Setenv("OPENROUTER_API_KEY", "")
|
||||
t.Setenv("ENABLEBANKING_APP_ID", "")
|
||||
t.Setenv("ENABLEBANKING_KEY_FILE", "")
|
||||
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
|
||||
a, err := app.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
state, err := a.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = a.Mutate(context.Background(), state.Revision, func(data *domain.Dataset) error {
|
||||
data.Accounts = append(data.Accounts, domain.Account{ID: "acc_eur", DisplayName: "Current", Currency: "EUR", Active: true})
|
||||
data.Tags = append(data.Tags, domain.Tag{ID: "tag_shared", Name: "Shared"}, domain.Tag{ID: "tag_work", Name: "Work"})
|
||||
for _, item := range []struct {
|
||||
id string
|
||||
amount domain.Money
|
||||
kind string
|
||||
category string
|
||||
tags []string
|
||||
}{
|
||||
{"tx_both", "-10.0000", "expense", domain.ExpenseFallback, []string{"tag_shared", "tag_work"}},
|
||||
{"tx_work", "-20.0000", "expense", domain.ExpenseFallback, []string{"tag_work"}},
|
||||
{"tx_income", "100.0000", "income", domain.IncomeFallback, []string{}},
|
||||
} {
|
||||
data.Transactions = append(data.Transactions, domain.Transaction{
|
||||
Facts: domain.Facts{ID: item.id, Source: "test", AccountID: "acc_eur", BookingDate: "2026-02-10",
|
||||
Amount: item.amount, Currency: "EUR", RawDescription: item.id, Fingerprint: item.id},
|
||||
Enrichment: domain.Enrichment{Kind: item.kind, CategoryID: item.category, TagIDs: item.tags},
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := New(a, fstest.MapFS{}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
include []string
|
||||
exclude []string
|
||||
want []analytics.Total
|
||||
}{
|
||||
{
|
||||
name: "repeated includes use union without duplication",
|
||||
include: []string{"tag_shared", "tag_work"},
|
||||
want: []analytics.Total{{Currency: "EUR", Expenses: "30.0000", Income: "0.0000", Net: "-30.0000"}},
|
||||
},
|
||||
{
|
||||
name: "repeated exclusions preserve untagged income",
|
||||
exclude: []string{"tag_shared", "tag_work"},
|
||||
want: []analytics.Total{{Currency: "EUR", Expenses: "0.0000", Income: "100.0000", Net: "100.0000"}},
|
||||
},
|
||||
{
|
||||
name: "include and exclude compose with exclusion winning",
|
||||
include: []string{"tag_shared", "tag_work"},
|
||||
exclude: []string{"tag_missing", "tag_shared"},
|
||||
want: []analytics.Total{{Currency: "EUR", Expenses: "20.0000", Income: "0.0000", Net: "-20.0000"}},
|
||||
},
|
||||
{
|
||||
name: "comma separated values are not a list",
|
||||
include: []string{"tag_shared,tag_work"},
|
||||
want: []analytics.Total{},
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
query := url.Values{"from": {"2026-02-01"}, "to": {"2026-02-28"}, "currency": {"EUR"}}
|
||||
for _, id := range tt.include {
|
||||
query.Add("tag_ids", id)
|
||||
}
|
||||
for _, id := range tt.exclude {
|
||||
query.Add("exclude_tag_ids", id)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost:8080/api/dashboard?"+query.Encode(), nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET dashboard: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got analytics.Dashboard
|
||||
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Totals, tt.want) {
|
||||
t.Fatalf("totals: got %#v, want %#v", got.Totals, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user