Screen 8 lists every distinct tag -- the ones the index holds and the ones rules.toml names -- with how many transactions carry it and the rules that write it, each by file position with the transactions it wins. It is where near-duplicate tags and tags several rules feed show up, and a rule beaten by a more specific one reads (0) there as it does on the rules screen. A tag no rule names any more says the next retag clears it. The transfer label is not a tag and is never listed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
517 lines
17 KiB
Go
517 lines
17 KiB
Go
package web
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.petrovv.com/nikola/money/internal/config"
|
||
"git.petrovv.com/nikola/money/internal/model"
|
||
"git.petrovv.com/nikola/money/internal/rules"
|
||
"git.petrovv.com/nikola/money/internal/store"
|
||
"git.petrovv.com/nikola/money/internal/transfers"
|
||
)
|
||
|
||
type fixtureTxn struct {
|
||
account, date, desc string
|
||
amount int64
|
||
}
|
||
|
||
// newTestServer builds a server over a data root holding rules.toml and an
|
||
// index with the given transactions, tagged and paired as an import would
|
||
// leave them. "Today" is fixed so the report's default window is predictable.
|
||
func newTestServer(t *testing.T, rulesToml string, txns ...fixtureTxn) (*Server, http.Handler) {
|
||
t.Helper()
|
||
root := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(root, config.RulesFile), []byte(rulesToml), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
db, err := store.Open(config.IndexPath(root))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { db.Close() })
|
||
|
||
ids := map[string][2]int64{}
|
||
for _, slug := range []string{"checking", "savings"} {
|
||
id, err := db.UpsertAccount(model.Account{Slug: slug, Name: slug, Currency: "EUR", MinorDigits: 2})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
src, err := db.SourceFile(id, slug+"/st.csv", "sha", "2026-01-01T00:00:00Z", 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ids[slug] = [2]int64{id, src}
|
||
}
|
||
for i, x := range txns {
|
||
if _, err := db.InsertTransaction(model.Transaction{
|
||
AccountID: ids[x.account][0], SourceFileID: ids[x.account][1],
|
||
Fingerprint: x.desc + x.date + string(rune('a'+i)),
|
||
Date: x.date, Description: x.desc, AmountMinor: x.amount,
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
loaded, err := config.LoadRules(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
engine, links := rules.New(loaded), transfers.New(loaded)
|
||
if _, err := engine.Retag(db); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, _, err := links.Link(db); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
s := New(root, db, nil, engine, links)
|
||
s.now = func() time.Time { return time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC) }
|
||
return s, s.Handler()
|
||
}
|
||
|
||
// call sends a request and decodes the JSON answer, failing unless the status
|
||
// is the one expected.
|
||
func call(t *testing.T, h http.Handler, method, path string, body any, want int, out any) {
|
||
t.Helper()
|
||
var r *http.Request
|
||
if body == nil {
|
||
r = httptest.NewRequest(method, path, nil)
|
||
} else {
|
||
b, err := json.Marshal(body)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r = httptest.NewRequest(method, path, bytes.NewReader(b))
|
||
r.Header.Set("Content-Type", "application/json")
|
||
}
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, r)
|
||
if w.Code != want {
|
||
t.Fatalf("%s %s: status %d, want %d: %s", method, path, w.Code, want, w.Body.String())
|
||
}
|
||
if out != nil {
|
||
if err := json.Unmarshal(w.Body.Bytes(), out); err != nil {
|
||
t.Fatalf("%s %s: decode %q: %v", method, path, w.Body.String(), err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func readRules(t *testing.T, s *Server) *config.Rules {
|
||
t.Helper()
|
||
r, err := config.LoadRules(s.root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return r
|
||
}
|
||
|
||
const lidlRule = `
|
||
[[rule]]
|
||
match = "*LIDL*"
|
||
tag = "groceries"
|
||
`
|
||
|
||
// The period narrows the report and nothing else: the report opens on last
|
||
// month while the transaction list still holds the whole index.
|
||
func TestReportPeriodLeavesTransactionsAlone(t *testing.T) {
|
||
_, h := newTestServer(t, lidlRule,
|
||
fixtureTxn{"checking", "2026-02-10", "LIDL SOFIA", -1000},
|
||
fixtureTxn{"checking", "2025-11-03", "LIDL VARNA", -500},
|
||
)
|
||
|
||
var rep reportJSON
|
||
call(t, h, "GET", "/api/report", nil, http.StatusOK, &rep)
|
||
if got := rep.Periods[rep.Period].Label; got != "last month" {
|
||
t.Fatalf("report opens on %q, want last month", got)
|
||
}
|
||
if len(rep.Rows) != 1 || rep.Rows[0].Out.Text != "10.00" {
|
||
t.Errorf("last month's report = %+v, want groceries 10.00 only", rep.Rows)
|
||
}
|
||
|
||
var txns struct{ Rows []txnRow }
|
||
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
|
||
if len(txns.Rows) != 2 {
|
||
t.Errorf("transactions = %d rows, want both", len(txns.Rows))
|
||
}
|
||
|
||
call(t, h, "GET", "/api/report?period=all+time", nil, http.StatusOK, &rep)
|
||
if len(rep.Rows) != 1 || rep.Rows[0].Out.Text != "15.00" {
|
||
t.Errorf("all-time report = %+v, want groceries 15.00", rep.Rows)
|
||
}
|
||
}
|
||
|
||
// The edit form has no type field, so the type must come from the rule on
|
||
// disk; a pattern the user was never shown is not one they chose to remove.
|
||
func TestEditKeepsTypePattern(t *testing.T) {
|
||
s, h := newTestServer(t, `
|
||
[[rule]]
|
||
match = "*LIDL*"
|
||
type = "CARD_PAYMENT"
|
||
tag = "groceries"
|
||
`)
|
||
expect := ruleJSON{Match: "*LIDL*", Type: "CARD_PAYMENT", Tag: "groceries"}
|
||
call(t, h, "PUT", "/api/rules/0", editRuleReq{
|
||
Rule: ruleForm{Match: "*LIDL SOFIA*", Tag: "food"}, Expect: expect,
|
||
}, http.StatusOK, nil)
|
||
|
||
got := readRules(t, s).Rule[0]
|
||
if got.Type != "CARD_PAYMENT" || got.Match != "*LIDL SOFIA*" || got.Tag != "food" {
|
||
t.Errorf("edited rule = %+v, want the type kept", got)
|
||
}
|
||
}
|
||
|
||
// Positions are what rules.toml is edited by, so a page that saw a different
|
||
// rule in that position must be refused rather than edit the wrong one.
|
||
func TestStalePositionIsRefused(t *testing.T) {
|
||
s, h := newTestServer(t, lidlRule)
|
||
before, _ := os.ReadFile(filepath.Join(s.root, config.RulesFile))
|
||
|
||
call(t, h, "POST", "/api/rules/delete", deleteRulesReq{
|
||
Positions: []int{0}, Expect: []ruleJSON{{Match: "*ZARA*", Tag: "clothes"}},
|
||
}, http.StatusConflict, nil)
|
||
call(t, h, "PUT", "/api/rules/0", editRuleReq{
|
||
Rule: ruleForm{Match: "*X*", Tag: "x"}, Expect: ruleJSON{Match: "*ZARA*", Tag: "clothes"},
|
||
}, http.StatusConflict, nil)
|
||
|
||
after, _ := os.ReadFile(filepath.Join(s.root, config.RulesFile))
|
||
if !bytes.Equal(before, after) {
|
||
t.Errorf("rules.toml changed on a refused request:\n%s", after)
|
||
}
|
||
}
|
||
|
||
// A body that is not JSON is refused before it reaches a handler: with no
|
||
// authentication, that is what stops a cross-site form posting to the server.
|
||
func TestFormPostsAreRefused(t *testing.T) {
|
||
s, h := newTestServer(t, "")
|
||
r := httptest.NewRequest("POST", "/api/rules", strings.NewReader("match=*&tag=x"))
|
||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, r)
|
||
if w.Code != http.StatusUnsupportedMediaType {
|
||
t.Fatalf("status %d, want 415", w.Code)
|
||
}
|
||
if n := len(readRules(t, s).Rule); n != 0 {
|
||
t.Errorf("%d rules written by a form post", n)
|
||
}
|
||
}
|
||
|
||
// Saving a rule writes rules.toml and retags, so the description it caught is
|
||
// no longer offered as untagged.
|
||
func TestCreateRuleRetags(t *testing.T) {
|
||
s, h := newTestServer(t, "",
|
||
fixtureTxn{"checking", "2026-02-10", "ZARA SOFIA", -1000},
|
||
)
|
||
var p rulePreviewJSON
|
||
call(t, h, "POST", "/api/rules/preview", rulePreviewReq{Glob: "*zara*"}, http.StatusOK, &p)
|
||
if p.Matches != 1 || p.Candidates != 1 || p.Rows[0].Marker != "▸" {
|
||
t.Fatalf("preview = %+v, want the one description matched", p)
|
||
}
|
||
|
||
call(t, h, "POST", "/api/rules", ruleForm{Match: "*ZARA*", Tag: "clothes"}, http.StatusOK, nil)
|
||
if r := readRules(t, s).Rule; len(r) != 1 || r[0].Tag != "clothes" {
|
||
t.Fatalf("rules.toml = %+v", r)
|
||
}
|
||
call(t, h, "POST", "/api/rules/preview", rulePreviewReq{}, http.StatusOK, &p)
|
||
if p.Candidates != 0 {
|
||
t.Errorf("after saving, %d descriptions still untagged", p.Candidates)
|
||
}
|
||
|
||
var list struct{ Rules []ruleRow }
|
||
call(t, h, "GET", "/api/rules", nil, http.StatusOK, &list)
|
||
if list.Rules[0].Usage != 1 {
|
||
t.Errorf("usage = %d, want 1", list.Rules[0].Usage)
|
||
}
|
||
}
|
||
|
||
// An edit's preview includes what the rule claims now, and keeps a description
|
||
// the new glob would let go of on screen, marked, instead of dropping it.
|
||
func TestEditPreviewShowsWhatIsLetGo(t *testing.T) {
|
||
_, h := newTestServer(t, lidlRule,
|
||
fixtureTxn{"checking", "2026-02-10", "LIDL SOFIA", -1000},
|
||
fixtureTxn{"checking", "2026-02-11", "LIDL VARNA", -1000},
|
||
)
|
||
edit := 0
|
||
var p rulePreviewJSON
|
||
call(t, h, "POST", "/api/rules/preview", rulePreviewReq{Glob: "*LIDL SOFIA*", Edit: &edit}, http.StatusOK, &p)
|
||
if p.Matches != 1 || p.Dropped != 1 {
|
||
t.Fatalf("preview = %+v, want 1 match and 1 dropped", p)
|
||
}
|
||
markers := map[string]string{}
|
||
for _, r := range p.Rows {
|
||
markers[r.Description] = r.Marker
|
||
}
|
||
if markers["LIDL SOFIA"] != "▸" || markers["LIDL VARNA"] != "−" {
|
||
t.Errorf("markers = %v", markers)
|
||
}
|
||
}
|
||
|
||
// A transfer is a pair or nothing: the preview shows a leg with no other side
|
||
// as unpaired, and only a matched pair leaves the report, with its fee named.
|
||
func TestTransferPairsLeaveTheReportWithTheirFee(t *testing.T) {
|
||
_, h := newTestServer(t, "",
|
||
fixtureTxn{"checking", "2026-02-01", "WIRE TO SAVINGS", -50000},
|
||
fixtureTxn{"savings", "2026-02-02", "WIRE FROM CHECKING", 49500},
|
||
fixtureTxn{"checking", "2026-02-20", "WIRE TO SAVINGS", -10000},
|
||
)
|
||
form := transferForm{
|
||
FromAccount: "checking", FromDesc: "*WIRE TO SAVINGS*",
|
||
ToAccount: "savings", ToDesc: "*WIRE FROM CHECKING*",
|
||
}
|
||
|
||
var p transferPreviewJSON
|
||
call(t, h, "POST", "/api/transfers/preview", form, http.StatusOK, &p)
|
||
if p.Pairs != 0 || p.Unpaired != 3 {
|
||
t.Fatalf("exact preview = %+v, want nothing paired on a mismatch", p)
|
||
}
|
||
|
||
form.Tolerance = "1.5"
|
||
call(t, h, "POST", "/api/transfers/preview", form, http.StatusOK, &p)
|
||
if p.Pairs != 1 || p.Unpaired != 1 || p.Fees != "5.00" {
|
||
t.Fatalf("tolerant preview = %+v, want 1 pair, 1 unpaired, 5.00 in fees", p)
|
||
}
|
||
|
||
call(t, h, "POST", "/api/transfers", form, http.StatusOK, nil)
|
||
|
||
var rep reportJSON
|
||
call(t, h, "GET", "/api/report?period=all+time", nil, http.StatusOK, &rep)
|
||
// The unpaired leg keeps counting; the pair is excluded with its fee.
|
||
if len(rep.Rows) != 1 || rep.Rows[0].Out.Text != "100.00" {
|
||
t.Errorf("report rows = %+v, want only the unpaired 100.00", rep.Rows)
|
||
}
|
||
if len(rep.Excluded) != 1 || rep.Excluded[0].Fee == nil || rep.Excluded[0].Fee.Text != "5.00" {
|
||
t.Errorf("excluded = %+v, want the 5.00 fee reported", rep.Excluded)
|
||
}
|
||
|
||
var list struct {
|
||
Transfers []transferRow
|
||
Unpaired int
|
||
}
|
||
call(t, h, "GET", "/api/transfers", nil, http.StatusOK, &list)
|
||
if list.Unpaired != 1 || list.Transfers[0].Paired != 1 || list.Transfers[0].Tolerance != "1.5%" {
|
||
t.Errorf("transfer list = %+v", list)
|
||
}
|
||
}
|
||
|
||
// The tolerance may be half typed while previewing, but saving refuses it
|
||
// rather than writing a silent zero.
|
||
func TestBadToleranceIsRefusedOnSave(t *testing.T) {
|
||
s, h := newTestServer(t, "")
|
||
call(t, h, "POST", "/api/transfers", transferForm{
|
||
FromAccount: "checking", FromDesc: "*A*", ToAccount: "savings", ToDesc: "*B*", Tolerance: "1,5",
|
||
}, http.StatusBadRequest, nil)
|
||
if n := len(readRules(t, s).Transfer); n != 0 {
|
||
t.Errorf("%d transfers written", n)
|
||
}
|
||
}
|
||
|
||
// The server outlives hand edits to rules.toml. The overview says when the
|
||
// file has moved on, and retag re-reads it, as a fresh `money retag` would.
|
||
func TestRetagRereadsRulesFromDisk(t *testing.T) {
|
||
s, h := newTestServer(t, "",
|
||
fixtureTxn{"checking", "2026-02-10", "ZARA", -1000},
|
||
)
|
||
var o overview
|
||
call(t, h, "GET", "/api/overview", nil, http.StatusOK, &o)
|
||
if o.Stale {
|
||
t.Fatal("stale before anything changed")
|
||
}
|
||
|
||
path := filepath.Join(s.root, config.RulesFile)
|
||
if err := os.WriteFile(path, []byte("[[rule]]\nmatch = \"*ZARA*\"\ntag = \"clothes\"\n"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
call(t, h, "GET", "/api/overview", nil, http.StatusOK, &o)
|
||
if !o.Stale {
|
||
t.Fatal("a hand edit to rules.toml is not reported")
|
||
}
|
||
|
||
call(t, h, "POST", "/api/retag", struct{}{}, http.StatusOK, nil)
|
||
call(t, h, "GET", "/api/overview", nil, http.StatusOK, &o)
|
||
if o.Stale {
|
||
t.Error("still stale after retag")
|
||
}
|
||
var txns struct{ Rows []txnRow }
|
||
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
|
||
if txns.Rows[0].Tag != "clothes" {
|
||
t.Errorf("tag = %q after retag, want clothes", txns.Rows[0].Tag)
|
||
}
|
||
}
|
||
|
||
// (transfer) is a label for a paired leg, never a tag: it is flagged as
|
||
// supplied, and never offered for completion.
|
||
func TestTransferLabelIsNotATag(t *testing.T) {
|
||
_, h := newTestServer(t, `
|
||
[[transfer]]
|
||
from_account = "checking"
|
||
from_desc = "*TO SAVINGS*"
|
||
to_account = "savings"
|
||
to_desc = "*FROM CHECKING*"
|
||
`,
|
||
fixtureTxn{"checking", "2026-02-01", "TO SAVINGS", -1000},
|
||
fixtureTxn{"savings", "2026-02-01", "FROM CHECKING", 1000},
|
||
)
|
||
var txns struct{ Rows []txnRow }
|
||
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
|
||
for _, r := range txns.Rows {
|
||
if r.Tag != model.TransferTag || !r.Supplied {
|
||
t.Errorf("row %+v, want a supplied transfer label", r)
|
||
}
|
||
}
|
||
call(t, h, "GET", "/api/transactions?untagged=1", nil, http.StatusOK, &txns)
|
||
if len(txns.Rows) != 0 {
|
||
t.Errorf("paired legs listed as untagged: %+v", txns.Rows)
|
||
}
|
||
var o overview
|
||
call(t, h, "GET", "/api/overview", nil, http.StatusOK, &o)
|
||
if len(o.Tags) != 0 {
|
||
t.Errorf("tags offered = %v, want none", o.Tags)
|
||
}
|
||
}
|
||
|
||
func TestPageIsServed(t *testing.T) {
|
||
_, h := newTestServer(t, "")
|
||
for _, path := range []string{"/", "/app.js", "/style.css"} {
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||
if w.Code != http.StatusOK || w.Body.Len() == 0 {
|
||
t.Errorf("GET %s: status %d, %d bytes", path, w.Code, w.Body.Len())
|
||
}
|
||
}
|
||
}
|
||
|
||
// A paired leg is spoken for, so it is neither listed as untagged nor offered
|
||
// to the rule builder; an unpaired leg is untagged like anything else, which
|
||
// is how it gets noticed.
|
||
func TestPairedLegsAreNotUntagged(t *testing.T) {
|
||
_, h := newTestServer(t, `
|
||
[[transfer]]
|
||
from_account = "checking"
|
||
from_desc = "*TO SAVINGS*"
|
||
to_account = "savings"
|
||
to_desc = "*FROM CHECKING*"
|
||
`,
|
||
fixtureTxn{"checking", "2026-02-01", "TRANSFER TO SAVINGS", -1000},
|
||
fixtureTxn{"savings", "2026-02-01", "TRANSFER FROM CHECKING", 1000},
|
||
fixtureTxn{"checking", "2026-02-20", "TRANSFER TO SAVINGS", -2000},
|
||
fixtureTxn{"checking", "2026-02-05", "LIDL SOFIA", -500},
|
||
)
|
||
|
||
var txns struct{ Rows []txnRow }
|
||
call(t, h, "GET", "/api/transactions?untagged=1", nil, http.StatusOK, &txns)
|
||
var seen []string
|
||
for _, r := range txns.Rows {
|
||
seen = append(seen, r.Date+" "+r.Description)
|
||
}
|
||
want := "2026-02-20 TRANSFER TO SAVINGS|2026-02-05 LIDL SOFIA"
|
||
if strings.Join(seen, "|") != want {
|
||
t.Errorf("untagged = %v, want only the unpaired leg and the shopping", seen)
|
||
}
|
||
|
||
var p rulePreviewJSON
|
||
call(t, h, "POST", "/api/rules/preview", rulePreviewReq{Glob: "*"}, http.StatusOK, &p)
|
||
for _, r := range p.Rows {
|
||
if r.Description == "TRANSFER FROM CHECKING" {
|
||
t.Error("the arriving leg of a matched transfer is still offered for tagging")
|
||
}
|
||
if r.Description == "TRANSFER TO SAVINGS" && r.Count != 1 {
|
||
t.Errorf("departing leg offered %d times, want only the unpaired one", r.Count)
|
||
}
|
||
}
|
||
}
|
||
|
||
// A ⚠ on the transfers screen opens onto the legs behind it: which side each
|
||
// is missing, and what that side would have to be.
|
||
func TestTransferListNamesTheMissingLegs(t *testing.T) {
|
||
_, h := newTestServer(t, `
|
||
[[transfer]]
|
||
from_account = "checking"
|
||
from_desc = "*TO SAVINGS*"
|
||
to_account = "savings"
|
||
to_desc = "*FROM CHECKING*"
|
||
`,
|
||
fixtureTxn{"checking", "2026-02-01", "TO SAVINGS", -1000},
|
||
fixtureTxn{"savings", "2026-02-02", "FROM CHECKING", 1000},
|
||
fixtureTxn{"checking", "2026-02-20", "TO SAVINGS", -2500},
|
||
fixtureTxn{"savings", "2026-01-10", "FROM CHECKING", 700},
|
||
)
|
||
var list struct {
|
||
Transfers []transferRow
|
||
Unpaired int
|
||
}
|
||
call(t, h, "GET", "/api/transfers", nil, http.StatusOK, &list)
|
||
legs := list.Transfers[0].Legs
|
||
if list.Unpaired != 2 || len(legs) != 2 {
|
||
t.Fatalf("unpaired = %d, legs = %+v; want the two lone legs", list.Unpaired, legs)
|
||
}
|
||
newest, oldest := legs[0], legs[1]
|
||
if newest.Movement != "checking → ?" || newest.Missing != "arriving" ||
|
||
newest.Want != "+25.00 in savings matching *FROM CHECKING*, dated 2026-02-15 to 2026-02-25" {
|
||
t.Errorf("newest leg = %+v", newest)
|
||
}
|
||
if oldest.Movement != "? → savings" || oldest.Missing != "leaving" ||
|
||
oldest.Want != "-7.00 in checking matching *TO SAVINGS*, dated 2026-01-05 to 2026-01-15" {
|
||
t.Errorf("oldest leg = %+v", oldest)
|
||
}
|
||
}
|
||
|
||
// The tags screen lists each tag once, with the transactions carrying it and
|
||
// every rule that writes it — a shadowed one at zero — and never the
|
||
// transfer label, which no rule wrote.
|
||
func TestTagListGroupsRulesByTag(t *testing.T) {
|
||
_, h := newTestServer(t, `
|
||
[[rule]]
|
||
match = "*LIDL*"
|
||
tag = "groceries"
|
||
|
||
[[rule]]
|
||
match = "*KAUFLAND*"
|
||
tag = "groceries"
|
||
|
||
[[rule]]
|
||
match = "*LIDL SOFIA*"
|
||
tag = "groceries"
|
||
|
||
[[rule]]
|
||
match = "*ZARA*"
|
||
tag = "clothes"
|
||
|
||
[[transfer]]
|
||
from_account = "checking"
|
||
from_desc = "*TO SAVINGS*"
|
||
to_account = "savings"
|
||
to_desc = "*FROM CHECKING*"
|
||
`,
|
||
fixtureTxn{"checking", "2026-02-01", "LIDL SOFIA", -1000},
|
||
fixtureTxn{"checking", "2026-02-02", "KAUFLAND", -500},
|
||
fixtureTxn{"checking", "2026-02-03", "TO SAVINGS", -100},
|
||
fixtureTxn{"savings", "2026-02-03", "FROM CHECKING", 100},
|
||
)
|
||
var res struct{ Tags []tagEntry }
|
||
call(t, h, "GET", "/api/tags", nil, http.StatusOK, &res)
|
||
if len(res.Tags) != 2 || res.Tags[0].Tag != "clothes" || res.Tags[1].Tag != "groceries" {
|
||
t.Fatalf("tags = %+v, want clothes and groceries only", res.Tags)
|
||
}
|
||
clothes, groceries := res.Tags[0], res.Tags[1]
|
||
if clothes.Transactions != 0 || len(clothes.Rules) != 1 {
|
||
t.Errorf("clothes = %+v, want its rule and no transactions", clothes)
|
||
}
|
||
if groceries.Transactions != 2 || len(groceries.Rules) != 3 {
|
||
t.Fatalf("groceries = %+v, want 2 transactions from 3 rules", groceries)
|
||
}
|
||
usage := map[string]int{}
|
||
for _, r := range groceries.Rules {
|
||
usage[r.Pattern] = r.Usage
|
||
}
|
||
if usage["*LIDL*"] != 0 || usage["*LIDL SOFIA*"] != 1 || usage["*KAUFLAND*"] != 1 {
|
||
t.Errorf("usage = %v, want *LIDL* shadowed by *LIDL SOFIA*", usage)
|
||
}
|
||
}
|