Serve the TUI's screens as a web app
money serve puts the seven screens in a browser: accounts, transactions, the report with its period axis and sort, the rule builder with its live preview and edit-in-place, the rules list, the transfer builder with its tolerance preview, and the transfers list. Import and retag are buttons in the header. It is one binary: the page is plain HTML, CSS and JavaScript embedded with go:embed, with no framework and no build step. It is a second frontend, not a second implementation. Amounts are formatted on the server, the rule preview is matched by glob.Match there, and the transfer preview runs transfers.Analyze, so the browser only lays out answers and cannot drift from the TUI on what a rule catches or what a pair costs. The server outlives hand edits to rules.toml in a way the TUI does not, so retag and import re-read it first, as a fresh `money retag` or `money import` would, and the overview says when the file on disk no longer matches what the index was derived from. Edits and deletes still go by position, but carry the rule they showed and are refused unless rules.toml still holds exactly that there; a position from a stale tab could otherwise name a different rule. An edit takes the type pattern from the rule on disk, never from the request. There is no authentication, by request. It listens on loopback unless --addr says otherwise, and writes must be sent as JSON so a cross-site form cannot post to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user