package rules import ( "path/filepath" "testing" "git.petrovv.com/nikola/money/internal/config" "git.petrovv.com/nikola/money/internal/model" "git.petrovv.com/nikola/money/internal/store" ) func testDB(t *testing.T) *store.DB { t.Helper() db, err := store.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) return db } // seed inserts transactions with no rule verdict yet. func seed(t *testing.T, db *store.DB, descriptions ...string) int64 { t.Helper() accountID, err := db.UpsertAccount(model.Account{ Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2, }) if err != nil { t.Fatal(err) } sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z", 0) if err != nil { t.Fatal(err) } for i, d := range descriptions { added, err := db.InsertTransaction(model.Transaction{ AccountID: accountID, SourceFileID: sourceID, Fingerprint: d, Date: "2026-01-0" + string(rune('1'+i)), Description: d, AmountMinor: -1000, }) if err != nil { t.Fatal(err) } if !added { t.Fatalf("seed %q was not inserted", d) } } return accountID } func byDescription(t *testing.T, db *store.DB, desc string) model.Transaction { t.Helper() txns, err := db.Transactions(store.Filter{}) if err != nil { t.Fatal(err) } for _, x := range txns { if x.Description == desc { return x } } t.Fatalf("no transaction with description %q", desc) return model.Transaction{} } // A tag is derived state: rules.toml is the only thing that decides it, so // retagging rewrites every tag from the current rules however often it runs, // including back to nothing when no rule matches any more. func TestRetagRewritesEveryTag(t *testing.T) { db := testDB(t) seed(t, db, "LIDL SOFIA", "SOME RANDOM SHOP") engine := New(&config.Rules{Rule: []config.Rule{ {Match: "*LIDL*", Tag: "groceries"}, {Match: "*SHOP*", Tag: "shopping"}, }}) n, err := engine.Retag(db) if err != nil { t.Fatal(err) } if n != 2 { t.Errorf("retagged %d rows, want 2", n) } if got := byDescription(t, db, "LIDL SOFIA").RuleTag; got != "groceries" { t.Errorf("tag = %q, want groceries", got) } // Re-running with the same rules changes nothing and says so. if n, err := engine.Retag(db); err != nil || n != 0 { t.Errorf("second retag: %d rows, %v; want 0 and no error", n, err) } // New rules win, and a row no rule matches loses its tag entirely. engine = New(&config.Rules{Rule: []config.Rule{ {Match: "*LIDL*", Tag: "food"}, }}) if _, err := engine.Retag(db); err != nil { t.Fatal(err) } if got := byDescription(t, db, "LIDL SOFIA").RuleTag; got != "food" { t.Errorf("tag = %q, want food", got) } if got := byDescription(t, db, "SOME RANDOM SHOP").RuleTag; got != "" { t.Errorf("tag = %q, want empty once no rule matches", got) } } // The narrower rule claims what it names wherever it sits in the file: the // catch-all above it must not swallow the exception written below. func TestMoreSpecificRuleWins(t *testing.T) { both := [][]config.Rule{ {{Match: "NIK*", Tag: "misc"}, {Match: "NIKOLA*", Tag: "family"}}, {{Match: "NIKOLA*", Tag: "family"}, {Match: "NIK*", Tag: "misc"}}, } for _, rs := range both { engine := New(&config.Rules{Rule: rs}) if tag := engine.Apply("checking", "NIKOLA PETROV"); tag != "family" { t.Errorf("%q first: tag = %q, want family", rs[0].Match, tag) } if tag := engine.Apply("checking", "NIKI TODOROV"); tag != "misc" { t.Errorf("%q first: tag = %q, want misc", rs[0].Match, tag) } } } // Specificity is measured on what the pattern spells out, so the wildcards // around a literal do not buy it precedence, and a bare "*" is always last. func TestSpecificityOrder(t *testing.T) { engine := New(&config.Rules{Rule: []config.Rule{ {Match: "*", Tag: "other"}, {Match: "*LIDL*", Tag: "groceries"}, {Match: "*LIDL EXPRESS*", Tag: "snacks"}, }}) for _, c := range []struct{ desc, want string }{ {"CARD LIDL EXPRESS 12", "snacks"}, {"CARD LIDL 12", "groceries"}, {"SOMETHING ELSE", "other"}, } { if tag := engine.Apply("checking", c.desc); tag != c.want { t.Errorf("%q: tag = %q, want %q", c.desc, tag, c.want) } } } // Two rules that are equally specific are still decided by the file: the // earlier one wins, which is what makes appending a rule safe. func TestEqualSpecificityKeepsFileOrder(t *testing.T) { engine := New(&config.Rules{Rule: []config.Rule{ {Match: "*ACME*", Tag: "first"}, {Match: "*ACME*", Tag: "second"}, }}) if tag := engine.Apply("checking", "ACME LTD"); tag != "first" { t.Errorf("tag = %q, want first", tag) } if tag := engine.Apply("checking", "SOMETHING ELSE"); tag != "" { t.Errorf("tag = %q, want empty for an unmatched description", tag) } } // A rule can be dead two ways: nothing matches it, or a rule that beats it // already claimed everything it would have caught. Usage must report both as // zero, indexed by file position however the rules are ordered for matching. func TestUsageCountsWinnersOnly(t *testing.T) { engine := New(&config.Rules{Rule: []config.Rule{ {Match: "*LIDL SOFIA*", Tag: "groceries"}, // the narrower of the two {Match: "*LIDL*", Tag: "shadowed"}, // every LIDL row here is Sofia {Match: "*NOTHING MATCHES ME*", Tag: "no"}, // matches nothing at all {Match: "*PAYROLL*", Tag: "salary"}, // claims one row }}) txns := []model.Transaction{ {AccountSlug: "checking", Description: "LIDL SOFIA 4412"}, {AccountSlug: "checking", Description: "LIDL SOFIA 9911"}, {AccountSlug: "checking", Description: "ACME PAYROLL"}, {AccountSlug: "checking", Description: "UNMATCHED SHOP"}, } usage := engine.Usage(txns) want := []int{2, 0, 0, 1} if len(usage) != len(want) { t.Fatalf("usage has %d entries, want %d", len(usage), len(want)) } for i := range want { if usage[i] != want[i] { t.Errorf("rule %d used by %d transactions, want %d", i+1, usage[i], want[i]) } } } // The index is the rule's position in rules.toml, not its position in the order // it was tried in — that is the number the rules screen shows and deletes by. func TestMatchIndex(t *testing.T) { engine := New(&config.Rules{Rule: []config.Rule{ {Match: "*LIDL*", Tag: "groceries"}, {Match: "*LIDL EXPRESS*", Tag: "snacks"}, }}) if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL EXPRESS 1"}); got != 1 { t.Errorf("index = %d, want 1", got) } if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL 1"}); got != 0 { t.Errorf("index = %d, want 0", got) } if got := engine.MatchIndex("checking", model.Transaction{Description: "OTHER"}); got != -1 { t.Errorf("index = %d, want -1 for no match", got) } } // Naming an account is itself a narrowing, so the scoped rule beats the // identical unscoped one even when the unscoped one is written first. func TestAccountScopedRule(t *testing.T) { engine := New(&config.Rules{Rule: []config.Rule{ {Match: "*TRANSFER*", Tag: "misc"}, {Match: "*TRANSFER*", Tag: "transfer", Account: "savings"}, }}) if tag := engine.Apply("savings", "TRANSFER FROM CHECKING"); tag != "transfer" { t.Errorf("savings: tag = %q, want transfer", tag) } if tag := engine.Apply("checking", "TRANSFER FROM CHECKING"); tag != "misc" { t.Errorf("checking: tag = %q, want misc", tag) } } // Besides the description, a rule can match the bank's own classification of // the transaction, on its own or alongside a description glob. func TestTypeRules(t *testing.T) { engine := New(&config.Rules{Rule: []config.Rule{ {Type: "CARD_PAYMENT", Match: "*LIDL*", Tag: "groceries"}, {Type: "ATM", Tag: "cash"}, }}) // A rule setting several patterns requires all of them to match. tag := engine.ApplyTxn("checking", model.Transaction{ Description: "LIDL SOFIA", Type: "CARD_PAYMENT", }) if tag != "groceries" { t.Errorf("tag = %q, want groceries", tag) } tag = engine.ApplyTxn("checking", model.Transaction{ Description: "LIDL SOFIA", Type: "TRANSFER", }) if tag != "" { t.Errorf("tag = %q, want empty: the type does not match", tag) } // Type alone works too. if tag := engine.ApplyTxn("checking", model.Transaction{ Description: "WITHDRAWAL", Type: "ATM", }); tag != "cash" { t.Errorf("tag = %q, want cash", tag) } }