Add persistent multi-tag include and exclude filters

This commit is contained in:
Lars Nolden
2026-09-19 12:44:05 +02:00
parent 8aab21fe9e
commit 9cc3130b4f
11 changed files with 409 additions and 46 deletions
+1 -1
View File
@@ -237,7 +237,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
respond(w, nil, errors.New("from must not exceed to"))
return
}
v, e := s.app.Dashboard(r.Context(), analytics.Filter{From: from, To: to, Currency: q.Get("currency"), AccountID: q.Get("account_id"), CategoryID: q.Get("category_id"), TagID: q.Get("tag_id"), MerchantID: q.Get("merchant_id")})
v, e := s.app.Dashboard(r.Context(), analytics.Filter{From: from, To: to, Currency: q.Get("currency"), AccountID: q.Get("account_id"), CategoryID: q.Get("category_id"), TagIDs: q["tag_ids"], ExcludeTagIDs: q["exclude_tag_ids"], MerchantID: q.Get("merchant_id")})
respond(w, v, e)
}
func (s *Server) account(w http.ResponseWriter, r *http.Request) {
+100
View File
@@ -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)
}
})
}
}