Skip to content

Commit

Permalink
Add validation of transaction proposal
Browse files Browse the repository at this point in the history
  • Loading branch information
mput committed Jun 23, 2024
1 parent 365a78e commit a67bbd2
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 62 deletions.
34 changes: 30 additions & 4 deletions app/ledger/ledger.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"github.com/mput/teledger/app/repo"
"github.com/mput/teledger/app/utils"
)

// Ledger is a wrapper around the ledger command line tool
Expand Down Expand Up @@ -85,13 +86,17 @@ func resolveIncludesReader(rs repo.Service, file string) (io.ReadCloser, error)

}

func (l *Ledger) execute(args ...string) (string, error) {
func (l *Ledger) executeWith(additional string, args ...string) (string, error) {
r, err := resolveIncludesReader(l.repo, l.mainFile)

if err != nil {
return "", fmt.Errorf("ledger file opening error: %v", err)
}

if additional != "" {
r = utils.MultiReadCloser(r, io.NopCloser( strings.NewReader(additional) ))
}

fargs := []string{"-f", "-"}
if l.strict {
fargs = append(fargs, "--pedantic")
Expand Down Expand Up @@ -133,6 +138,11 @@ func (l *Ledger) execute(args ...string) (string, error) {
return out.String(), nil
}

func (l *Ledger) execute(args ...string) (string, error) {
return l.executeWith("", args...)
}



func (l *Ledger) Execute(args ...string) (string, error) {
err := l.repo.Init()
Expand Down Expand Up @@ -180,6 +190,11 @@ func (l *Ledger) validate() error {
return err
}

func (l *Ledger) validateWith(addition string) error {
_, err := l.execute("balance")
return err
}


func (l *Ledger) AddComment(comment string) (string, error) {
err := l.repo.Init()
Expand Down Expand Up @@ -235,9 +250,12 @@ type Transaction struct {
Comment string
}

func (t Transaction) toString() string {
func (t *Transaction) toString() string {
var res strings.Builder
res.WriteString(fmt.Sprintf("%s %s\n", t.Date.Format("2006-01-02"), t.Description))
if t.Comment != "" {
res.WriteString(fmt.Sprintf(";; %s: %s\n",t.Date.Format("2006-01-02 15:04:05 Monday"), t.Comment))
}
res.WriteString(fmt.Sprintf("%s * %s\n", t.Date.Format("2006-01-02"), t.Description))
for _, p := range t.Postings {
// format float to 2 decimal places
res.WriteString(fmt.Sprintf(" %s %8.2f %s\n", p.Account, p.Amount, p.Currency))
Expand Down Expand Up @@ -376,6 +394,11 @@ func (l *Ledger) proposeTransaction(userInput string) (Transaction, error) {
return Transaction{}, fmt.Errorf("unable to generate transaction: %v", err)
}

_, err = l.executeWith(trx.toString(), "balance")
if err != nil {
return Transaction{}, fmt.Errorf("unable to validate transaction: %v", err)
}

return trx, nil

}
Expand All @@ -391,7 +414,10 @@ func (l *Ledger) ProposeTransaction(userInput string, times int) (tr Transaction
return tr, err
}

for ; times > 0 ; times-- {
for i := 0; i < times; i++ {
if i > 0 {
slog.Warn("retrying transaction generation", "attempt", i)
}
tr, err = l.proposeTransaction(userInput)
if err == nil {
return tr, nil
Expand Down
136 changes: 81 additions & 55 deletions app/ledger/ledger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,58 +166,59 @@ dummy dummy


func TestLedger_ProposeTransaction(t *testing.T) {
t.Run("happy path", func(t *testing.T) {

mockCall := 0

mockedTransactionGenerator := &TransactionGeneratorMock{
GenerateTransactionFunc: func(p PromptCtx) (mocktr Transaction,err error) {
mockCall++
dt, _ := time.Parse(time.RFC3339, "2014-11-12T11:45:26.371Z")
// On the first attempt, return transaction that is not valid
// for the test Ledger file
if mockCall == 1 {
mocktr = Transaction{
Date: dt,
Description: "My tr",
Postings: []Posting{
Posting{
Account: "cash",
Amount: -30.43,
Currency: "EUR",
},
Posting{
Account: "taxi",
Amount: 30.43,
Currency: "EUR",
},
mockCall := 0

var mockedTransactionGenerator *TransactionGeneratorMock

mockedTransactionGenerator = &TransactionGeneratorMock{
GenerateTransactionFunc: func(p PromptCtx) (mocktr Transaction,err error) {
mockCall++
dt, _ := time.Parse(time.RFC3339, "2014-11-12T11:45:26.371Z")
// On the first attempt, return transaction that is not valid
// for the test Ledger file
if len(mockedTransactionGenerator.calls.GenerateTransaction) == 1 {
mocktr = Transaction{
Date: dt,
Description: "My tr",
Comment: "invalid transaction",
Postings: []Posting{
Posting{
Account: "cash",
Amount: -30.43,
Currency: "EUR",
},
}
} else {
mocktr = Transaction{
Date: dt,
Comment: p.UserInput,
Description: "Tacos",
Postings: []Posting{
Posting{
Account: "Assets:Cash",
Amount: -30.43,
Currency: "EUR",
},
Posting{
Account: "Food",
Amount: 30.43,
Currency: "EUR",
},
Posting{
Account: "taxi",
Amount: 30.43,
Currency: "EUR",
},
}
},
}
return
} else {
mocktr = Transaction{
Date: dt,
Comment: "valid transaction",
Description: "Tacos",
Postings: []Posting{
Posting{
Account: "Assets:Cash",
Amount: -30.43,
Currency: "EUR",
},
Posting{
Account: "Food",
Amount: 30.43,
Currency: "EUR",
},
},
}
}
return

},
}
},
}

const testFile = `
const testFile = `
commodity EUR
commodity USD
Expand All @@ -230,18 +231,23 @@ account Equity
Equity
`

ledger := NewLedger(
&repo.Mock{Files: map[string]string{"main.ledger": testFile}},
mockedTransactionGenerator,
"main.ledger",
true,
)
ledger := NewLedger(
&repo.Mock{Files: map[string]string{"main.ledger": testFile}},
mockedTransactionGenerator,
"main.ledger",
true,
)

t.Run("happy path", func(t *testing.T) {

_, err := ledger.ProposeTransaction("20 Taco Bell", 3)

tr, err := ledger.ProposeTransaction("20 Taco Bell", 5)

assert.NoError(t, err)

assert.Equal(t, len(mockedTransactionGenerator.calls.GenerateTransaction), 1)
assert.Equal(t, len(mockedTransactionGenerator.calls.GenerateTransaction), 2)

assert.Equal(t, "valid transaction", tr.Comment)

assert.Equal(t,
PromptCtx{
Expand All @@ -252,6 +258,26 @@ account Equity
mockedTransactionGenerator.calls.GenerateTransaction[0].PromptCtx,
)

assert.Equal(t,
`;; 2014-11-12 11:45:26 Wednesday: valid transaction
2014-11-12 * Tacos
Assets:Cash -30.43 EUR
Food 30.43 EUR
`,
tr.toString(),
)
})

t.Run("validation error path", func(t *testing.T) {
mockedTransactionGenerator.ResetCalls()

_, err := ledger.ProposeTransaction("20 Taco Bell", 1)

assert.ErrorContains(t, err, "Unknown account 'cash'")

assert.Equal(t, len(mockedTransactionGenerator.calls.GenerateTransaction), 1)

})


}
9 changes: 6 additions & 3 deletions app/repo/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,29 @@ import (

type Mock struct {
Files map[string]string
files map[string]string
inited bool
}

func (r *Mock) Init() error {
if r.inited {
return fmt.Errorf("already initialized")
}
r.files = r.Files
r.inited = true
return nil
}

func (r *Mock) Free() {
r.inited = false
r.files = nil
}

func (r *Mock) Open(file string) (io.ReadCloser, error) {
if !r.inited {
return nil, fmt.Errorf("not initialized")
}
if content, ok := r.Files[file]; ok {
if content, ok := r.files[file]; ok {
return io.NopCloser(strings.NewReader(content)), nil
}
return nil, fmt.Errorf("file not found")
Expand All @@ -46,7 +49,7 @@ func (r *Mock) OpenForAppend(file string) (io.WriteCloser, error) {
if !r.inited {
return nil, fmt.Errorf("not initialized")
}
if content, ok := r.Files[file]; ok {
if content, ok := r.files[file]; ok {
return &WriteCloserT{r: r, f: file, dt: []byte(content)}, nil
}
return nil, fmt.Errorf("file not found")
Expand All @@ -64,7 +67,7 @@ func (w *WriteCloserT) Write(p []byte) (n int, err error) {
}

func (w *WriteCloserT) Close() error {
w.r.Files[w.f] = string(w.dt)
w.r.files[w.f] = string(w.dt)
return nil
}

Expand Down
27 changes: 27 additions & 0 deletions app/utils/multi_readcloser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package utils

import "io"
type multiReadCloser struct {
closers []io.Closer
reader io.Reader
}
func (mc *multiReadCloser) Close() error {
var err error
for i := range mc.closers {
err = mc.closers[i].Close()
}
return err
}
func (mc *multiReadCloser) Read(p []byte) (int, error) {
return mc.reader.Read(p)
}
func MultiReadCloser(readClosers ...io.ReadCloser) io.ReadCloser {
cs := make([]io.Closer, len(readClosers))
rs := make([]io.Reader, len(readClosers))
for i := range readClosers {
cs[i] = readClosers[i]
rs[i] = readClosers[i]
}
r := io.MultiReader(rs...)
return &multiReadCloser{cs, r}
}

0 comments on commit a67bbd2

Please sign in to comment.