Files
money/internal/web/server_test.go
T
nikolaandClaude Opus 5.5 3d72a54be1 Add a tags screen
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>
2026-10-02 23:30:38 +02:00

517 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}