From a67bbd2d52ffb0b8bc6e6fa09840caf2e9f82477 Mon Sep 17 00:00:00 2001 From: mput Date: Sun, 23 Jun 2024 23:22:51 +0100 Subject: [PATCH] Add validation of transaction proposal --- app/ledger/ledger.go | 34 ++++++++- app/ledger/ledger_test.go | 136 ++++++++++++++++++++-------------- app/repo/mock.go | 9 ++- app/utils/multi_readcloser.go | 27 +++++++ 4 files changed, 144 insertions(+), 62 deletions(-) create mode 100644 app/utils/multi_readcloser.go diff --git a/app/ledger/ledger.go b/app/ledger/ledger.go index 6434ff5..4e76d23 100644 --- a/app/ledger/ledger.go +++ b/app/ledger/ledger.go @@ -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 @@ -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") @@ -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() @@ -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() @@ -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)) @@ -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 } @@ -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 diff --git a/app/ledger/ledger_test.go b/app/ledger/ledger_test.go index 26508ca..e0fadfb 100644 --- a/app/ledger/ledger_test.go +++ b/app/ledger/ledger_test.go @@ -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 @@ -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{ @@ -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) }) + + } diff --git a/app/repo/mock.go b/app/repo/mock.go index ac85d94..19cc210 100644 --- a/app/repo/mock.go +++ b/app/repo/mock.go @@ -10,6 +10,7 @@ import ( type Mock struct { Files map[string]string + files map[string]string inited bool } @@ -17,19 +18,21 @@ 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") @@ -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") @@ -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 } diff --git a/app/utils/multi_readcloser.go b/app/utils/multi_readcloser.go new file mode 100644 index 0000000..d7915a0 --- /dev/null +++ b/app/utils/multi_readcloser.go @@ -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} +}