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") 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) } }