From 6e45cf36b1b3677f75a767a4c440308eea7e53ec Mon Sep 17 00:00:00 2001 From: Luca Bernstein Date: Mon, 12 Sep 2022 17:46:49 +0200 Subject: [PATCH] Implement custom field names (#186) --- ACKNOWLEDGEMENTS.md | 5 + bot/config.go | 9 +- bot/controller.go | 31 +- bot/controller_test.go | 18 +- bot/replyKeyboards.go | 3 + bot/suggestions.go | 36 +- bot/suggestions_test.go | 13 +- bot/templates.go | 13 +- bot/templates_test.go | 11 +- bot/transactionBuilder.go | 465 ++++++++++++-------- bot/transactionBuilder_test.go | 84 ++-- db/crud/bot_cache.go | 31 +- db/crud/bot_cache_test.go | 31 +- db/crud/monitoring.go | 11 +- db/crud/monitoring_test.go | 14 +- db/migrations/controller.go | 1 + db/migrations/v11.go | 96 ++++ go.mod | 1 + go.sum | 2 + helpers/arrays_test.go | 2 - helpers/constants.go | 32 +- helpers/constants_test.go | 11 +- scenarioTests/features/health.feature | 6 +- scenarioTests/features/keyboard.feature | 19 +- scenarioTests/features/list.feature | 27 +- scenarioTests/features/templates.feature | 4 +- scenarioTests/features/transactions.feature | 6 +- scenarioTests/steps/bot.py | 6 +- web/health/monitoring.go | 6 +- 29 files changed, 614 insertions(+), 380 deletions(-) create mode 100644 db/migrations/v11.go diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md index 2c8bacc..e6ff98f 100644 --- a/ACKNOWLEDGEMENTS.md +++ b/ACKNOWLEDGEMENTS.md @@ -39,6 +39,11 @@ Errors. https://github.com/pkg/errors. Copyright (c) 2015, Dave Cheney . BSD-2-Clause "Simplified" License (https://github.com/pkg/errors/blob/master/LICENSE). + +Structs. +https://github.com/fatih/structs. +Copyright (c) 2014 Fatih Arslan. +MIT license (https://github.com/fatih/structs/blob/master/LICENSE). ``` --- diff --git a/bot/config.go b/bot/config.go index bcefa6c..311b36f 100644 --- a/bot/config.go +++ b/bot/config.go @@ -411,11 +411,7 @@ func (bc *BotController) configHandleAccountDelete(m *tb.Message, params ...stri func (bc *BotController) deleteUserData(m *tb.Message) { errors := errors{operation: "user deletion", bc: bc, m: m} - errors.handle2(bc.Repo.DeleteCacheEntries(m, helpers.STX_ACCT, "")) - errors.handle2(bc.Repo.DeleteCacheEntries(m, helpers.STX_ACCF, "")) - errors.handle2(bc.Repo.DeleteCacheEntries(m, helpers.STX_AMTF, "")) - errors.handle2(bc.Repo.DeleteCacheEntries(m, helpers.STX_DATE, "")) - errors.handle2(bc.Repo.DeleteCacheEntries(m, helpers.STX_DESC, "")) + errors.handle1(bc.Repo.DeleteAllCacheEntries(m)) errors.handle1(bc.Repo.UserSetNotificationSetting(m, -1, -1)) @@ -443,6 +439,3 @@ func (e *errors) handle1(err error) { e.bc.Logf(ERROR, e.m, "Handling error for operation '%s' (failing silently, proceeding): %s", e.operation, err.Error()) } } -func (e *errors) handle2(_ interface{}, err error) { - e.handle1(err) -} diff --git a/bot/controller.go b/bot/controller.go index d14c060..ed6ffc9 100644 --- a/bot/controller.go +++ b/bot/controller.go @@ -55,31 +55,6 @@ func (bc *BotController) AddBotAndStart(b IBot) { b.Handle(tb.OnText, bc.handleTextState) - // Todo: Add generic callback handler - // Route callback by ID splits - bc.Bot.Handle(&btnSuggListAccFrom, func(c *tb.Callback) { - bc.Logf(DEBUG, nil, "Handling callback on button. Chat: %d", c.Message.Chat.ID) - c.Message.Text = "/suggestions list accFrom" - // TODO: What happens in group chats? - c.Message.Sender = &tb.User{ID: c.Message.Chat.ID} // hack to send chat user a message (in private chats userId = chatId) - bc.suggestionsHandler(c.Message) - bc.Bot.Respond(c, &tb.CallbackResponse{}) // Always respond - }) - bc.Bot.Handle(&btnSuggListAccTo, func(c *tb.Callback) { - bc.Logf(DEBUG, nil, "Handling callback on button. Chat: %d", c.Message.Chat.ID) - c.Message.Text = "/suggestions list accTo" - c.Message.Sender = &tb.User{ID: c.Message.Chat.ID} - bc.suggestionsHandler(c.Message) - bc.Bot.Respond(c, &tb.CallbackResponse{}) // Always respond - }) - bc.Bot.Handle(&btnSuggListTxDesc, func(c *tb.Callback) { - bc.Logf(DEBUG, nil, "Handling callback on button. Chat: %d", c.Message.Chat.ID) - c.Message.Text = "/suggestions list txDesc" - c.Message.Sender = &tb.User{ID: c.Message.Chat.ID} - bc.suggestionsHandler(c.Message) - bc.Bot.Respond(c, &tb.CallbackResponse{}) // Always respond - }) - bc.Logf(TRACE, nil, "Starting bot '%s'", b.Me().Username) if bc.CronScheduler != nil { @@ -640,7 +615,7 @@ func (bc *BotController) handleTextState(m *tb.Message) { return } else if state == ST_TX { tx := bc.State.GetTx(m) - err := tx.Input(m) + _, err := tx.Input(m) if err != nil { bc.Logf(WARN, m, "Invalid text state input: '%s'. Err: %s", m.Text, err.Error()) _, err := bc.Bot.Send(Recipient(m), "Your last input seems to have not worked.\n"+ @@ -672,7 +647,7 @@ func (bc *BotController) handleTextState(m *tb.Message) { func (bc *BotController) sendNextTxHint(hint *Hint, m *tb.Message) { replyKeyboard := ReplyKeyboard(hint.KeyboardOptions) bc.Logf(TRACE, m, "Sending hints for next step: %v", hint.KeyboardOptions) - _, err := bc.Bot.Send(Recipient(m), escapeCharacters(hint.Prompt, "(", ")", "."), replyKeyboard, tb.ModeMarkdownV2) + _, err := bc.Bot.Send(Recipient(m), escapeCharacters(hint.Prompt, "(", ")", ".", "!"), replyKeyboard, tb.ModeMarkdownV2) if err != nil { bc.Logf(ERROR, m, "Sending bot message failed: %s", err.Error()) } @@ -707,7 +682,7 @@ func (bc *BotController) finishTransaction(m *tb.Message, tx Tx) { } // TODO: Goroutine - err = bc.Repo.PutCacheHints(m, tx.DataKeys()) + err = bc.Repo.PutCacheHints(m, tx.CacheData()) if err != nil { bc.Logf(ERROR, m, "Something went wrong while caching transaction. Error: %s", err.Error()) // Don't return, instead continue flow (if recording was successful) diff --git a/bot/controller_test.go b/bot/controller_test.go index 6e997aa..563a0dd 100644 --- a/bot/controller_test.go +++ b/bot/controller_test.go @@ -78,10 +78,10 @@ func TestTextHandlingWithoutPriorState(t *testing.T) { // Create simple tx and fill it completely bc.commandCreateSimpleTx(&tb.Message{Chat: chat}) tx := bc.State.txStates[12345] - tx.Input(&tb.Message{Text: "17.34"}) // amount - tx.Input(&tb.Message{Text: "Assets:Wallet"}) // from - tx.Input(&tb.Message{Text: "Expenses:Groceries"}) // to - bc.handleTextState(&tb.Message{Chat: chat, Text: "Buy something in the grocery store"}) // description (via handleTextState) + tx.Input(&tb.Message{Text: "17.34"}) // amount + tx.Input(&tb.Message{Text: "Buy something in the grocery store"}) // description + tx.Input(&tb.Message{Text: "Assets:Wallet"}) // from + bc.handleTextState(&tb.Message{Chat: chat, Text: "Expenses:Groceries"}) // to (via handleTextState) // After the first tx is done, send some command m := &tb.Message{Chat: chat} @@ -118,7 +118,7 @@ func TestStartTransactionWithPlainAmountThousandsSeparated(t *testing.T) { bc.handleTextState(&tb.Message{Chat: chat, Text: "1,000,000"}) debugString := bc.State.txStates[12345].Debug() - expected := "data=[1000000.00" + expected := "data=map[amount::${SPACE_FORMAT}1000000.00" helpers.TestStringContains(t, debugString, expected, "contain parsed amount") if err := mock.ExpectationsWereMet(); err != nil { @@ -394,10 +394,10 @@ func TestTimezoneOffsetForAutomaticDate(t *testing.T) { // Create simple tx and fill it completely bc.commandCreateSimpleTx(&tb.Message{Chat: chat}) tx := bc.State.txStates[12345] - tx.Input(&tb.Message{Text: "17.34"}) // amount - tx.Input(&tb.Message{Text: "Assets:Wallet"}) // from - tx.Input(&tb.Message{Text: "Expenses:Groceries"}) // to - bc.handleTextState(&tb.Message{Chat: chat, Text: "Buy something in the grocery store"}) // description (via handleTextState) + tx.Input(&tb.Message{Text: "17.34"}) // amount + tx.Input(&tb.Message{Text: "Buy something in the grocery store"}) // description + tx.Input(&tb.Message{Text: "Assets:Wallet"}) // from + bc.handleTextState(&tb.Message{Chat: chat, Text: "Expenses:Groceries"}) // to (via handleTextState) // After the first tx is done, send some command m := &tb.Message{Chat: chat} diff --git a/bot/replyKeyboards.go b/bot/replyKeyboards.go index 3f36d9d..ce3fef0 100644 --- a/bot/replyKeyboards.go +++ b/bot/replyKeyboards.go @@ -5,6 +5,9 @@ import ( ) func ReplyKeyboard(buttons []string) *tb.ReplyMarkup { + if len(buttons) == 0 { + return clearKeyboard() + } kb := &tb.ReplyMarkup{ResizeReplyKeyboard: true, OneTimeKeyboard: true} buttonsCreated := []tb.Row{} for _, label := range buttons { diff --git a/bot/suggestions.go b/bot/suggestions.go index 62ad9d5..befbf63 100644 --- a/bot/suggestions.go +++ b/bot/suggestions.go @@ -8,12 +8,11 @@ import ( tb "gopkg.in/tucnak/telebot.v2" ) -var ( - suggestionsMenu = &tb.ReplyMarkup{} - btnSuggListAccFrom = suggestionsMenu.Data("/suggestions list accFrom", "btnSuggestionsListAccFrom") - btnSuggListAccTo = suggestionsMenu.Data("/suggestions list accTo", "btnSuggestionsListAccTo") - btnSuggListTxDesc = suggestionsMenu.Data("/suggestions list txDesc", "btnSuggestionsListTxDesc") -) +func isAllowedSuggestionType(s string) bool { + splits := strings.SplitN(s, ":", 2) + _, exists := TEMPLATE_TYPE_HINTS[Type(splits[0])] + return exists +} func (bc *BotController) suggestionsHandler(m *tb.Message) { sc := h.MakeSubcommandHandler("/"+CMD_SUGGEST, true) @@ -28,24 +27,24 @@ func (bc *BotController) suggestionsHandler(m *tb.Message) { } func (bc *BotController) suggestionsHelp(m *tb.Message, err error) { - suggestionTypes := strings.Join(h.AllowedSuggestionTypes(), ", ") + suggestionTypes := []string{} + for _, suggType := range h.AllowedSuggestionTypes() { + if suggType == h.FIELD_ACCOUNT { + suggType += ":[from,to,...]" + } + suggestionTypes = append(suggestionTypes, suggType) + } errorMsg := "" if err != nil { errorMsg += fmt.Sprintf("Error executing your command: %s\n\n", err.Error()) } - suggestionsMenu.Inline( - suggestionsMenu.Row(btnSuggListAccFrom), - suggestionsMenu.Row(btnSuggListAccTo), - suggestionsMenu.Row(btnSuggListTxDesc), - ) - _, err = bc.Bot.Send(Recipient(m), errorMsg+fmt.Sprintf(`Usage help for /suggestions: /suggestions list /suggestions add /suggestions rm [value] -Parameter is one from: [%s]`, suggestionTypes), suggestionsMenu) +Parameter is one from: [%s]`, strings.Join(suggestionTypes, ", "))) if err != nil { bc.Logf(ERROR, m, "Sending bot message failed: %s", err.Error()) } @@ -57,7 +56,8 @@ func (bc *BotController) suggestionsHandleList(m *tb.Message, params ...string) bc.suggestionsHelp(m, fmt.Errorf("error encountered while retrieving suggestions list: %s", err.Error())) return } - if !h.ArrayContainsC(h.AllowedSuggestionTypes(), p.T, false) { + p.T = h.FqCacheKey(p.T) + if !isAllowedSuggestionType(p.T) { bc.suggestionsHelp(m, fmt.Errorf("unexpected subcommand")) return } @@ -93,7 +93,8 @@ func (bc *BotController) suggestionsHandleAdd(m *tb.Message, params ...string) { bc.suggestionsHelp(m, fmt.Errorf("error encountered while retrieving suggestions list: %s", err.Error())) return } - if !h.ArrayContainsC(h.AllowedSuggestionTypes(), p.T, false) { + p.T = h.FqCacheKey(p.T) + if !isAllowedSuggestionType(p.T) { bc.suggestionsHelp(m, fmt.Errorf("unexpected subcommand")) return } @@ -121,7 +122,8 @@ func (bc *BotController) suggestionsHandleRemove(m *tb.Message, params ...string bc.suggestionsHelp(m, fmt.Errorf("error encountered while retrieving suggestions list: %s", err.Error())) return } - if !h.ArrayContainsC(h.AllowedSuggestionTypes(), p.T, false) { + p.T = h.FqCacheKey(p.T) + if !isAllowedSuggestionType(p.T) { bc.suggestionsHelp(m, fmt.Errorf("unexpected subcommand")) return } diff --git a/bot/suggestions_test.go b/bot/suggestions_test.go index c9aa237..8322f61 100644 --- a/bot/suggestions_test.go +++ b/bot/suggestions_test.go @@ -19,11 +19,11 @@ func TestSuggestionsHandlingWithSpaces(t *testing.T) { } mock. ExpectExec(`DELETE FROM "bot::cache"`). - WithArgs(12345, "txDesc", "Some description with spaces"). + WithArgs(12345, "description:", "Some description with spaces"). WillReturnResult(sqlmock.NewResult(1, 1)) mock. ExpectExec(`DELETE FROM "bot::cache"`). - WithArgs(12345, "txDesc", "SomeDescriptionWithoutSpaces"). + WithArgs(12345, "description:", "SomeDescriptionWithoutSpaces"). WillReturnResult(sqlmock.NewResult(1, 1)) bc := NewBotController(db) @@ -36,6 +36,7 @@ func TestSuggestionsHandlingWithSpaces(t *testing.T) { if !strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Usage help") { t.Errorf("MissingType: Bot unexpectedly did not send usage help: %s", bot.LastSentWhat) } + log.Print(1) // missing type bc.commandSuggestions(&tb.Message{Text: "/suggestions rm", Chat: chat}) @@ -43,23 +44,23 @@ func TestSuggestionsHandlingWithSpaces(t *testing.T) { t.Errorf("MissingType: Bot unexpectedly did not send usage help: %s", bot.LastSentWhat) } - bc.commandSuggestions(&tb.Message{Text: "/suggestions rm txDesc Too Many arguments with spaces", Chat: chat}) + bc.commandSuggestions(&tb.Message{Text: "/suggestions rm description Too Many arguments with spaces", Chat: chat}) if !strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Usage help") { t.Errorf("TooManyArgs: Bot unexpectedly did not send usage help: %s", bot.LastSentWhat) } - bc.commandSuggestions(&tb.Message{Text: "/suggestions rm txDesc \"Some description with spaces\"", Chat: chat}) + bc.commandSuggestions(&tb.Message{Text: "/suggestions rm description \"Some description with spaces\"", Chat: chat}) if strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Usage help") { t.Errorf("Spaced: Bot unexpectedly sent usage help instead of performing command: %s", bot.LastSentWhat) } - bc.commandSuggestions(&tb.Message{Text: "/suggestions rm txDesc \"SomeDescriptionWithoutSpaces\"", Chat: chat}) + bc.commandSuggestions(&tb.Message{Text: "/suggestions rm description \"SomeDescriptionWithoutSpaces\"", Chat: chat}) if strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Usage help") { t.Errorf("NotSpaced: Bot unexpectedly sent usage help instead of performing command: %s", bot.LastSentWhat) } // Add is missing required value - bc.commandSuggestions(&tb.Message{Text: "/suggestions add txDesc ", Chat: chat}) + bc.commandSuggestions(&tb.Message{Text: "/suggestions add description ", Chat: chat}) if !strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Usage help") { t.Errorf("AddMissingValue: Bot did not send error: %s", bot.LastSentWhat) } diff --git a/bot/templates.go b/bot/templates.go index 7b695e6..7373ed7 100644 --- a/bot/templates.go +++ b/bot/templates.go @@ -112,8 +112,9 @@ func (bc *BotController) templatesHandleAdd(m *tb.Message, params ...string) { - ${amount}, ${-amount}, ${amount/i} (e.g. ${amount/2}) - ${date} - ${description} -- ${from} -- ${to} +- ${account:from} +- ${account:to} +- ${account::} Example: @@ -212,13 +213,7 @@ func (bc *BotController) templatesUse(m *tb.Message, params ...string) error { bc.finishTransaction(m, tx) return nil } - // TODO: Refactor hint := tx.NextHint(bc.Repo, m) - replyKeyboard := ReplyKeyboard(hint.KeyboardOptions) - bc.Logf(TRACE, m, "Sending hints for next step: %v", hint.KeyboardOptions) - _, err = bc.Bot.Send(Recipient(m), hint.Prompt, replyKeyboard) - if err != nil { - bc.Logf(ERROR, m, "Sending bot message failed: %s", err.Error()) - } + bc.sendNextTxHint(hint, m) return nil } diff --git a/bot/templates_test.go b/bot/templates_test.go index 09cf1ae..264d430 100644 --- a/bot/templates_test.go +++ b/bot/templates_test.go @@ -176,11 +176,14 @@ func TestTemplateUse(t *testing.T) { fromFix ${-amount} toFix1 ${amount/2} toFix2 ${amount/2}`)) - mock. ExpectQuery(`SELECT "value" FROM "bot::userSetting"`). WithArgs(chat.ID, helpers.USERSET_CUR). WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("TEST_CURRENCY")) + bc.commandTemplates(&tb.Message{Chat: chat, Text: "/t test 2022-04-11"}) + helpers.TestStringContains(t, fmt.Sprintf("%v", bot.AllLastSentWhat[len(bot.AllLastSentWhat)-2]), "Creating a new transaction from your template 'test'", "template tx starting msg") + helpers.TestStringContains(t, fmt.Sprintf("%v", bot.LastSentWhat), "amount", "asking for amount") + mock. ExpectQuery(`SELECT "value" FROM "bot::userSetting"`). WithArgs(chat.ID, helpers.USERSET_CUR). @@ -193,7 +196,6 @@ func TestTemplateUse(t *testing.T) { ExpectQuery(`SELECT "value" FROM "bot::userSetting"`). WithArgs(chat.ID, helpers.USERSET_TZOFF). WillReturnRows(sqlmock.NewRows([]string{"value"})) - mock. ExpectExec(regexp.QuoteMeta(`INSERT INTO "bot::transaction" ("tgChatId", "value") VALUES ($1, $2);`)). @@ -203,11 +205,6 @@ func TestTemplateUse(t *testing.T) { toFix2 5.255 EUR_TEST `). WillReturnResult(sqlmock.NewResult(1, 1)) - - bc.commandTemplates(&tb.Message{Chat: chat, Text: "/t test 2022-04-11"}) - helpers.TestStringContains(t, fmt.Sprintf("%v", bot.AllLastSentWhat[len(bot.AllLastSentWhat)-2]), "Creating a new transaction from your template 'test'", "template tx starting msg") - helpers.TestStringContains(t, fmt.Sprintf("%v", bot.LastSentWhat), "amount", "asking for amount") - tx := bc.State.txStates[chatId(chat.ID)] tx.Input(&tb.Message{Text: "10.51 EUR_TEST"}) // amount bc.handleTextState(&tb.Message{Chat: chat, Text: "Buy something"}) // description (via handleTextState) diff --git a/bot/transactionBuilder.go b/bot/transactionBuilder.go index b0e6769..1839168 100644 --- a/bot/transactionBuilder.go +++ b/bot/transactionBuilder.go @@ -2,7 +2,9 @@ package bot import ( "fmt" + "log" "math" + "sort" "strconv" "strings" "time" @@ -10,6 +12,7 @@ import ( "github.com/LucaBernstein/beancount-bot-tg/db/crud" c "github.com/LucaBernstein/beancount-bot-tg/helpers" + "github.com/fatih/structs" tb "gopkg.in/tucnak/telebot.v2" ) @@ -18,13 +21,11 @@ type Hint struct { KeyboardOptions []string } -type command string -type data string - type Input struct { key string hint *Hint handler func(m *tb.Message) (string, error) + field TemplateField } func HandleFloat(m *tb.Message) (string, error) { @@ -89,7 +90,7 @@ func HandleFloat(m *tb.Message) (string, error) { } else { finalAmount = values[0] } - return ParseAmount(finalAmount) + currency, nil + return FORMATTER_PLACEHOLDER + ParseAmount(finalAmount) + currency, nil } func handleThousandsSeparators(value string) (cleanValue string, err error) { @@ -153,161 +154,296 @@ func ParseDate(m string) (string, error) { } type Tx interface { - Input(*tb.Message) error + Prepare() Tx + Input(*tb.Message) (bool, error) IsDone() bool Debug() string NextHint(*crud.Repo, *tb.Message) *Hint EnrichHint(r *crud.Repo, m *tb.Message, i Input) *Hint FillTemplate(currency, tag string, tzOffset int) (string, error) - DataKeys() map[string]string + CacheData() map[string]string - addStep(command command, hint string, handler func(m *tb.Message) (string, error)) Tx SetDate(string) (Tx, error) setTimeIfEmpty(tzOffset int) bool } type SimpleTx struct { - template string - steps []command - stepDetails map[command]Input - data []data - date_upd string - step int + template string + userCurrencySuggestion string + + nextFields []*TemplateField + data map[string]string +} + +type TemplateHintData struct { + Raw string + + FieldName string + FieldSpecifier string + FieldHint string + FieldDefault string // This is not filled by field parsing logic yet. Instead values can be passed in. +} + +type Type string +type HintTemplate struct { + Text string + Handler func(m *tb.Message) (string, error) +} + +var TEMPLATE_TYPE_HINTS = map[Type]HintTemplate{ + Type(c.FIELD_AMOUNT): { + Text: "Please enter the *amount* of money {{.FieldHint}} (e.g. '12.34' or '12.34 {{.FieldDefault}}')", + Handler: HandleFloat, + }, + Type(c.FIELD_ACCOUNT): { + Text: "Please enter the *account* {{.FieldHint}} (or select one from the list)", + Handler: HandleRaw, + }, + Type(c.FIELD_DESCRIPTION): { + Text: "Please enter a *description* {{.FieldHint}} (or select one from the list)", + Handler: HandleRaw, + }, } const TEMPLATE_SIMPLE_DEFAULT = `${date} * "${description}"${tag} - ${from} ${-amount} - ${to}` + ${account:from:the money came *from*} ${-amount} + ${account:to:the money went *to*}` func CreateSimpleTx(suggestedCur, template string) (Tx, error) { tx := (&SimpleTx{ - stepDetails: make(map[command]Input), - template: template, - }). - addStep("amount", fmt.Sprintf("Please enter the *amount* of money (e.g. '12.34' or '12.34 %s')", suggestedCur), HandleFloat). - addStep("from", "Please enter the *account* the money came *from* (or select one from the list)", HandleRaw). - addStep("to", "Please enter the *account* the money went *to* (or select one from the list)", HandleRaw). - addStep("description", "Please enter a *description* (or select one from the list)", HandleRaw) + data: make(map[string]string), + template: template, + userCurrencySuggestion: suggestedCur, + }).Prepare() return tx, nil } +func (tx *SimpleTx) CacheData() (data map[string]string) { + fieldOrder := []string{} + fields := ParseTemplateFields(tx.template, "") + for _, f := range fields { + if !c.ArrayContains(c.AllowedSuggestionTypes(), c.TypeCacheKey(f.FieldIdentifierForValue())) { + // Don't cache non-suggestible data + continue + } + fieldOrder = append(fieldOrder, f.FieldIdentifierForValue()) + } + cleanedData := make(map[string]string) + for k, d := range tx.data { + if !c.ArrayContains(fieldOrder, k) { + continue + } + cleanedData[k] = strings.ReplaceAll(d, FORMATTER_PLACEHOLDER, "") + } + log.Print(cleanedData) + return cleanedData +} + +func (tx *SimpleTx) Prepare() Tx { + tx.nextFields = ParseTemplateFields(tx.template, tx.userCurrencySuggestion) + tx.cleanNextFields() + return tx +} + func (tx *SimpleTx) SetDate(d string) (Tx, error) { date, err := ParseDate(d) if err != nil { return nil, err } - tx.date_upd = date + tx.data[c.FqCacheKey(c.FIELD_DATE)] = date return tx, nil } -func ParseTemplateFields(template string) (fields map[string]*TemplateField) { - fields = make(map[string]*TemplateField) - varBegins := strings.SplitAfter(template, "${") - for _, v := range varBegins { - field := ParseTemplateField(strings.Split(v, "}")[0]) - fields[field.Raw] = field +func (tx *SimpleTx) setTimeIfEmpty(tzOffset int) bool { + if tx.data[c.FqCacheKey(c.FIELD_DATE)] == "" { + // set today as fallback/default date + timezoneOff := time.Duration(tzOffset) * time.Hour + tx.data[c.FqCacheKey(c.FIELD_DATE)] = time.Now().UTC().Add(timezoneOff).Format(c.BEANCOUNT_DATE_FORMAT) + return true } - return + return false } -type TemplateField struct { - Name string +func (tx *SimpleTx) setTagIfEmpty(tag string) bool { + if tx.data[c.FqCacheKey(c.FIELD_TAG)] == "" { + tagS := "" + if tag != "" { + tagS += " #" + tag + } + tx.data[c.FqCacheKey(c.FIELD_TAG)] = tagS + return true + } + return false +} - Fraction int +func SortTemplateFields(unsortedFields []*TemplateField) []*TemplateField { + sortMapping := map[string]int{ + c.FIELD_AMOUNT: 1, + c.FIELD_DESCRIPTION: 2, + c.FIELD_ACCOUNT: 3, + } + sort.Slice(unsortedFields, func(i, j int) bool { + if unsortedFields[i].FieldName == unsortedFields[j].FieldName { + sortedSpecifiers := []string{unsortedFields[i].FieldSpecifier, unsortedFields[j].FieldSpecifier} + sort.Strings(sortedSpecifiers) + return unsortedFields[i].FieldSpecifier == sortedSpecifiers[0] + } + a, exists := sortMapping[unsortedFields[i].FieldName] + if !exists { + a = len(sortMapping) + 1 + } + b, exists := sortMapping[unsortedFields[j].FieldName] + if !exists { + b = len(sortMapping) + 1 + } + return a < b + }) + return unsortedFields +} + +func ParseTemplateFields(template, currencySuggestion string) []*TemplateField { + varBegins := strings.Split(template, "${") + if len(varBegins) > 1 { + varBegins = varBegins[1:] + } + unsortedFields := []*TemplateField{} + for _, v := range varBegins { + field := ParseTemplateField(strings.Split(v, "}")[0], currencySuggestion) + unsortedFields = append(unsortedFields, field) + } + return SortTemplateFields(unsortedFields) +} +type NumberConfig struct { + Fraction int IsNegative bool +} - Raw string +type TemplateField struct { + TemplateHintData + NumberConfig } -func ParseTemplateField(rawField string) *TemplateField { +func (tf *TemplateField) FieldIdentifierForValue() string { + return tf.FieldName + ":" + tf.FieldSpecifier +} + +func ParseTemplateField(rawField, currencySuggestion string) *TemplateField { + rawField = strings.TrimSpace(rawField) field := &TemplateField{ - Raw: rawField, - Name: rawField, - Fraction: 1, + TemplateHintData{ + Raw: rawField, + }, + NumberConfig{}, + } + + splitFieldByColon := strings.Split(rawField, ":") + field.FieldName = strings.TrimSpace(splitFieldByColon[0]) + if len(splitFieldByColon) >= 2 { + field.FieldSpecifier = strings.TrimSpace(splitFieldByColon[1]) + } + if len(splitFieldByColon) >= 3 { + field.FieldHint = strings.TrimSpace(splitFieldByColon[2]) + } + if field.FieldHint == "" && field.FieldSpecifier != "" { + field.FieldHint = fmt.Sprintf("*%s*", field.FieldSpecifier) } - field.IsNegative = strings.HasPrefix(field.Name, "-") - field.Name = strings.TrimLeft(field.Name, "-") + field.IsNegative = strings.HasPrefix(field.FieldName, "-") + field.FieldName = strings.TrimLeft(field.FieldName, "-") - fractionSplits := strings.Split(field.Name, "/") + fractionSplits := strings.Split(field.FieldName, "/") + field.FieldName = fractionSplits[0] + field.Fraction = 1 if len(fractionSplits) == 2 { - field.Name = fractionSplits[0] + field.FieldName = fractionSplits[0] var err error field.Fraction, err = strconv.Atoi(fractionSplits[1]) if err != nil { - c.LogLocalf(ERROR, nil, "converting fraction for template failed: '%s' -> %s", rawField, err.Error()) + c.LogLocalf(WARN, nil, "converting fraction for template failed: '%s' -> %s", rawField, err.Error()) field.Fraction = 1 } - } else { - field.Name = fractionSplits[0] + if field.Fraction == 0 { + c.LogLocalf(WARN, nil, "fraction was 0. Setting to 1: '%s' -> %s", rawField, err.Error()) + field.Fraction = 1 + } + } + field.FieldName = fractionSplits[0] + + if field.FieldName == c.FIELD_AMOUNT { + field.FieldDefault = currencySuggestion } return field } -func (tx *SimpleTx) addStep(command command, hint string, handler func(m *tb.Message) (string, error)) Tx { - templateFields := ParseTemplateFields(tx.template) - exists := false - for _, f := range templateFields { - if f.Name == string(command) { - exists = true - } - } - if !exists { - return tx +func (tx *SimpleTx) Input(m *tb.Message) (isDone bool, err error) { + nextField := tx.nextFields[0] + hint := TEMPLATE_TYPE_HINTS[Type(nextField.FieldName)] + res, err := hint.Handler(m) + if err != nil { + return tx.IsDone(), err } - tx.steps = append(tx.steps, command) - tx.stepDetails[command] = Input{key: string(command), hint: &Hint{Prompt: hint}, handler: handler} - tx.data = make([]data, len(tx.steps)) - return tx + tx.data[nextField.FieldIdentifierForValue()] = res + return tx.IsDone(), nil } -func (tx *SimpleTx) Input(m *tb.Message) (err error) { - res, err := tx.stepDetails[tx.steps[tx.step]].handler(m) - if err != nil { - return err +func (tx *SimpleTx) cleanNextFields() { + if len(tx.nextFields) > 0 { + nextField := tx.nextFields[0] + _, isDataFilled := tx.data[nextField.FieldIdentifierForValue()] + _, isFieldAutoFilled := TEMPLATE_TYPE_HINTS[Type(nextField.FieldName)] + if isDataFilled || !isFieldAutoFilled { + tx.nextFields = tx.nextFields[1:] + tx.cleanNextFields() + return + } } - tx.data[tx.step] = (data)(res) - tx.step++ - return } func (tx *SimpleTx) NextHint(r *crud.Repo, m *tb.Message) *Hint { - if tx.step > len(tx.steps)-1 { + if len(tx.nextFields) == 0 { crud.LogDbf(r, TRACE, m, "During extraction of next hint an error ocurred: step exceeds max index.") return nil } - return tx.EnrichHint(r, m, tx.stepDetails[tx.steps[tx.step]]) + nextField := tx.nextFields[0] + hint := TEMPLATE_TYPE_HINTS[Type(nextField.FieldName)] + message, err := c.Template(hint.Text, structs.Map(nextField.TemplateHintData)) + if err != nil { + crud.LogDbf(r, TRACE, m, "During message building an error ocurred: "+err.Error()) + return nil + } + return tx.EnrichHint(r, m, Input{ + key: nextField.FieldName, + hint: &Hint{ + Prompt: message, + }, + handler: hint.Handler, + field: *nextField, + }) } func (tx *SimpleTx) EnrichHint(r *crud.Repo, m *tb.Message, i Input) *Hint { crud.LogDbf(r, TRACE, m, "Enriching hint (%s).", i.key) - if i.key == "description" { + if i.key == c.FIELD_DESCRIPTION { return tx.hintDescription(r, m, i.hint) } - if i.key == "date" { - return tx.hintDate(i.hint) - } - if c.ArrayContains([]string{"from", "to"}, i.key) { + if i.key == c.FIELD_ACCOUNT { return tx.hintAccount(r, m, i) } return i.hint } func (tx *SimpleTx) hintAccount(r *crud.Repo, m *tb.Message, i Input) *Hint { - crud.LogDbf(r, TRACE, m, "Enriching hint: account (key=%s)", i.key) + accountFQSpecifier := i.field.FieldIdentifierForValue() + crud.LogDbf(r, TRACE, m, "Enriching hint: '%s'", accountFQSpecifier) var ( res []string = nil err error = nil ) - if i.key == "from" { - res, err = r.GetCacheHints(m, c.STX_ACCF) - } else if i.key == "to" { - res, err = r.GetCacheHints(m, c.STX_ACCT) - } + res, err = r.GetCacheHints(m, accountFQSpecifier) if err != nil { - crud.LogDbf(r, ERROR, m, "Error occurred getting cached hint (hintAccount): %s", err.Error()) + crud.LogDbf(r, ERROR, m, "Error occurred getting cached hint (%s): %s", accountFQSpecifier, err.Error()) return i.hint } i.hint.KeyboardOptions = res @@ -315,7 +451,7 @@ func (tx *SimpleTx) hintAccount(r *crud.Repo, m *tb.Message, i Input) *Hint { } func (tx *SimpleTx) hintDescription(r *crud.Repo, m *tb.Message, h *Hint) *Hint { - res, err := r.GetCacheHints(m, c.STX_DESC) + res, err := r.GetCacheHints(m, c.FqCacheKey(c.FIELD_DESCRIPTION)) if err != nil { crud.LogDbf(r, ERROR, m, "Error occurred getting cached hint (hintDescription): %s", err.Error()) } @@ -323,32 +459,45 @@ func (tx *SimpleTx) hintDescription(r *crud.Repo, m *tb.Message, h *Hint) *Hint return h } -func (tx *SimpleTx) hintDate(h *Hint) *Hint { - h.KeyboardOptions = []string{"today"} - return h -} - -func (tx *SimpleTx) DataKeys() map[string]string { - varMap := make(map[string]string) - varMap["date"] = tx.date_upd - for i, v := range tx.steps { - varMap[string(v)] = string(tx.data[i]) - } - return varMap -} - func (tx *SimpleTx) IsDone() bool { - return tx.step >= len(tx.steps) + tx.cleanNextFields() + return len(tx.nextFields) == 0 } -func (tx *SimpleTx) setTimeIfEmpty(tzOffset int) bool { - if tx.date_upd == "" { - // set today as fallback/default date - timezoneOff := time.Duration(tzOffset) * time.Hour - tx.date_upd = time.Now().UTC().Add(timezoneOff).Format(c.BEANCOUNT_DATE_FORMAT) - return true +const FORMATTER_PLACEHOLDER = "${SPACE_FORMAT}" + +func formatAllLinesWithFormatterPlaceholder(s string, dotIndentation int, currency string) string { + rebuiltString := "" + for _, line := range strings.Split(s, "\n") { + if strings.Contains(line, FORMATTER_PLACEHOLDER) { + splits := strings.SplitN(line, FORMATTER_PLACEHOLDER, 2) + firstPart, secondPart := strings.TrimSpace(splits[0]), strings.TrimSpace(splits[1]) + // in case there are multiple occurrences in one line: + secondPart = strings.TrimSpace(strings.ReplaceAll(secondPart, FORMATTER_PLACEHOLDER, "")) + firstPart = fmt.Sprintf(" %s ", firstPart) // Two leading spaces and one trailing for separation + if !strings.Contains(secondPart, " ") { + secondPart += " " + currency + } + if strings.Contains(secondPart, ".") { + dotSplits := strings.SplitN(secondPart, ".", 2) + runeCount := 0 + runeCount += utf8.RuneCountInString(firstPart) + runeCount += utf8.RuneCountInString(dotSplits[0]) + spacesNeeded := dotIndentation - runeCount + 1 // one dot char + if spacesNeeded < 0 { + spacesNeeded = 0 + } + rebuiltString += firstPart + " " + strings.Repeat(" ", spacesNeeded) + secondPart + "\n" + } else { + rebuiltString += firstPart + " " + secondPart + "\n" + } + } else if line == "" { + rebuiltString += line + } else { + rebuiltString += line + "\n" + } } - return false + return rebuiltString } func (tx *SimpleTx) FillTemplate(currency, tag string, tzOffset int) (string, error) { @@ -357,83 +506,52 @@ func (tx *SimpleTx) FillTemplate(currency, tag string, tzOffset int) (string, er } // If still empty, set time and correct for timezone tx.setTimeIfEmpty(tzOffset) - - varMap := tx.DataKeys() + tx.setTagIfEmpty(tag) template := tx.template - fields := ParseTemplateFields(tx.template) - var amountFields []*TemplateField + fields := ParseTemplateFields(tx.template, "") for _, f := range fields { - // TODO: Refactor! - value := f.Raw - if f.Name == "amount" { - amountFields = append(amountFields, f) - continue // Only replace last for formatting - } else if f.Name == "description" { - if v, exists := varMap["description"]; exists { - value = v - } - } else if f.Name == "tag" { - tagS := "" - if tag != "" { - tagS += " #" + tag - } - value = tagS - } else if f.Name == "date" { - if v, exists := varMap["date"]; exists { - value = v - } - } else if f.Name == "from" { - if v, exists := varMap["from"]; exists { - value = v + value, exists := tx.data[f.FieldIdentifierForValue()] + if exists { + value, err := applyFieldOptionsForNumbersIfApplicable(value, f) + if err != nil { + return "", err } - } else if f.Name == "to" { - if v, exists := varMap["to"]; exists { - value = v + template = strings.ReplaceAll(template, fmt.Sprintf("${%s}", f.Raw), value) + } + } + template = formatAllLinesWithFormatterPlaceholder(template, c.DOT_INDENT, currency) + return strings.TrimSpace(template) + "\n", nil +} + +func applyFieldOptionsForNumbersIfApplicable(value string, f *TemplateField) (string, error) { + splits := strings.SplitN(value, FORMATTER_PLACEHOLDER, 2) + if len(splits) > 1 { + leftSide, rightSide := splits[0], splits[1] + if f.IsNegative { + if strings.HasPrefix(rightSide, "-") { + rightSide = rightSide[1:] + } else { + rightSide = "-" + rightSide } - } else { - continue } - template = strings.ReplaceAll(template, fmt.Sprintf("${%s}", f.Raw), value) - } - for _, amountField := range amountFields { - if v, exists := varMap["amount"]; exists { - amount := strings.Split(v, " ") - if len(amount) >= 2 { - // amount input contains currency - currency = amount[1] + if f.Fraction > 1 { + amountSplits := strings.SplitN(rightSide, " ", 2) + amountLeft := amountSplits[0] + currency := "" + if len(amountSplits) > 1 { + currency = amountSplits[1] } - f, err := strconv.ParseFloat(amount[0], 64) + amountParsed, err := strconv.ParseFloat(amountLeft, 64) if err != nil { return "", err } - - oldTemplate := template - template = "" - for _, line := range strings.Split(oldTemplate, "\n") { - if strings.Contains(line, fmt.Sprintf("${%s}", amountField.Raw)) { - before := strings.Split(line, fmt.Sprintf("${%s}", amountField.Raw))[0] - spacesNeeded := c.DOT_INDENT - utf8.RuneCountInString(before) - fractionedAmount := f / float64(amountField.Fraction) - spacesNeeded -= CountLeadingDigits(fractionedAmount) // float length before point - spacesNeeded += 2 // indentation - negSign := "" - if amountField.IsNegative { - negSign = "-" - spacesNeeded -= 1 - } - if spacesNeeded < 0 { - spacesNeeded = 0 - } - addSpacesFrom := strings.Repeat(" ", spacesNeeded) // DOT_INDENT: 47 chars from account start to dot - template += strings.ReplaceAll(line, fmt.Sprintf("${%s}", amountField.Raw), addSpacesFrom+negSign+ParseAmount(fractionedAmount)+" "+currency) + "\n" - } else { - template += line + "\n" - } - } + amountParsed /= float64(f.Fraction) + rightSide = ParseAmount(amountParsed) + " " + currency } + return leftSide + FORMATTER_PLACEHOLDER + rightSide, nil } - return strings.TrimSpace(template) + "\n", nil + return value, nil } func ParseAmount(f float64) string { @@ -449,14 +567,5 @@ func ParseAmount(f float64) string { } func (tx *SimpleTx) Debug() string { - return fmt.Sprintf("SimpleTx{step=%d, totalSteps=%d, data=%v}", tx.step, len(tx.steps), tx.data) -} - -func CountLeadingDigits(f float64) int { - count := 1 - for f >= 10 { - f /= 10 - count++ - } - return count + return fmt.Sprintf("SimpleTx{remainingFields=%v, data=%v}", len(tx.nextFields), tx.data) } diff --git a/bot/transactionBuilder_test.go b/bot/transactionBuilder_test.go index a043f54..aa3221d 100644 --- a/bot/transactionBuilder_test.go +++ b/bot/transactionBuilder_test.go @@ -12,6 +12,21 @@ import ( tb "gopkg.in/tucnak/telebot.v2" ) +func TestTypesTemplateHintsOnlyContainsAllowed(t *testing.T) { + var definedBot []string + for key := range bot.TEMPLATE_TYPE_HINTS { + definedBot = append(definedBot, string(key)) + } + if len(helpers.AllowedSuggestionTypes()) > len(definedBot) { + t.Errorf("Defined types mismatch (len)") + } + for _, key := range helpers.AllowedSuggestionTypes() { + if !helpers.ArrayContains(definedBot, key) { + t.Errorf("Defined types mismatch (undefined '%s')", key) + } + } +} + func TestHandleFloat(t *testing.T) { _, err := bot.HandleFloat(&tb.Message{Text: "Hello World!"}) if err == nil { @@ -20,64 +35,64 @@ func TestHandleFloat(t *testing.T) { handledFloat, err := bot.HandleFloat(&tb.Message{Text: "27.5"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 27.5") - helpers.TestExpect(t, handledFloat, "27.50", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"27.50", "") handledFloat, err = bot.HandleFloat(&tb.Message{Text: "27,8"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 27,8") - helpers.TestExpect(t, handledFloat, "27.80", "Should come out as clean float") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"27.80", "Should come out as clean float") handledFloat, err = bot.HandleFloat(&tb.Message{Text: " 27,12 "}) helpers.TestExpect(t, err, nil, "Should not throw an error for 27,12") - helpers.TestExpect(t, handledFloat, "27.12", "Should come out as clean float (2)") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"27.12", "Should come out as clean float (2)") handledFloat, err = bot.HandleFloat(&tb.Message{Text: "1.23456"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 1.23456") - helpers.TestExpect(t, handledFloat, "1.23456", "Should work for precise floats") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"1.23456", "Should work for precise floats") handledFloat, err = bot.HandleFloat(&tb.Message{Text: "4.44 USD_CUSTOM"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 4.44 USD_CUSTOM") - helpers.TestExpect(t, handledFloat, "4.44 USD_CUSTOM", "Should include custom currency") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"4.44 USD_CUSTOM", "Should include custom currency") handledFloat, err = bot.HandleFloat(&tb.Message{Text: "-5.678"}) helpers.TestExpect(t, err, nil, "Should not throw an error for -5.678") - helpers.TestExpect(t, handledFloat, "5.678", "Should use absolute value") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"5.678", "Should use absolute value") } func TestHandleFloatSimpleCalculations(t *testing.T) { // Additions should work handledFloat, err := bot.HandleFloat(&tb.Message{Text: "10+3"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 10+3") - helpers.TestExpect(t, handledFloat, "13.00", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"13.00", "") handledFloat, err = bot.HandleFloat(&tb.Message{Text: "11.45+3,12345"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 11.45+3,12345") - helpers.TestExpect(t, handledFloat, "14.57345", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"14.57345", "") handledFloat, err = bot.HandleFloat(&tb.Message{Text: "006+9.999"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 006+9.999") - helpers.TestExpect(t, handledFloat, "15.999", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"15.999", "") // Multiplications should work handledFloat, err = bot.HandleFloat(&tb.Message{Text: "10*3"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 10*3") - helpers.TestExpect(t, handledFloat, "30.00", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"30.00", "") handledFloat, err = bot.HandleFloat(&tb.Message{Text: "10*3,12345"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 10*3,12345") - helpers.TestExpect(t, handledFloat, "31.2345", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"31.2345", "") handledFloat, err = bot.HandleFloat(&tb.Message{Text: "001.1*3.5"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 001.1*3.5") - helpers.TestExpect(t, handledFloat, "3.85", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"3.85", "") // Simple calculations also work with currencies handledFloat, err = bot.HandleFloat(&tb.Message{Text: "11*3 TEST_CUR"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 11*3 TEST_CUR") - helpers.TestExpect(t, handledFloat, "33.00 TEST_CUR", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"33.00 TEST_CUR", "") handledFloat, err = bot.HandleFloat(&tb.Message{Text: "14.5+16+1+1+3 ANOTHER_CURRENCY"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 14.5+16+1+1+3 ANOTHER_CURRENCY") - helpers.TestExpect(t, handledFloat, "35.50 ANOTHER_CURRENCY", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"35.50 ANOTHER_CURRENCY", "") // Check some error behaviors // Mixed calculation operators @@ -110,23 +125,23 @@ func TestHandleFloatSimpleCalculations(t *testing.T) { func TestHandleFloatThousandsSeparator(t *testing.T) { handledFloat, err := bot.HandleFloat(&tb.Message{Text: "100,000,000.00"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 100 million with comma thousands separator") - helpers.TestExpect(t, handledFloat, "100000000.00", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"100000000.00", "") handledFloat, err = bot.HandleFloat(&tb.Message{Text: "100.000.000,00"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 100 million with dot thousands separator") - helpers.TestExpect(t, handledFloat, "100000000.00", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"100000000.00", "") handledFloat, err = bot.HandleFloat(&tb.Message{Text: "24,123.7"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 24,123.7") - helpers.TestExpect(t, handledFloat, "24123.70", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"24123.70", "") handledFloat, err = bot.HandleFloat(&tb.Message{Text: "1.000.000"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 1.000.000") - helpers.TestExpect(t, handledFloat, "1000000.00", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"1000000.00", "") handledFloat, err = bot.HandleFloat(&tb.Message{Text: "1,000,000"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 1,000,000") - helpers.TestExpect(t, handledFloat, "1000000.00", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"1000000.00", "") _, err = bot.HandleFloat(&tb.Message{Text: "24,24.7"}) if err == nil || !strings.Contains(err.Error(), "invalid separators in value '24,24.7'") { @@ -150,7 +165,7 @@ func TestHandleFloatThousandsSeparator(t *testing.T) { handledFloat, err = bot.HandleFloat(&tb.Message{Text: "1,000.00+24"}) helpers.TestExpect(t, err, nil, "Should not throw an error for 1,000.00+24") - helpers.TestExpect(t, handledFloat, "1024.00", "") + helpers.TestExpect(t, handledFloat, bot.FORMATTER_PLACEHOLDER+"1024.00", "") } func TestTransactionBuilding(t *testing.T) { @@ -159,9 +174,9 @@ func TestTransactionBuilding(t *testing.T) { t.Errorf("Error creating simple tx: %s", err.Error()) } tx.Input(&tb.Message{Text: "17"}) // amount + tx.Input(&tb.Message{Text: "Buy something in the grocery store"}) // description tx.Input(&tb.Message{Text: "Assets:Wallet"}) // from tx.Input(&tb.Message{Text: "Expenses:Groceries"}) // to - tx.Input(&tb.Message{Text: "Buy something in the grocery store"}) // description if !tx.IsDone() { t.Errorf("With given input transaction data should be complete for SimpleTx") @@ -184,9 +199,9 @@ func TestTransactionBuildingCustomCurrencyInAmount(t *testing.T) { t.Errorf("Error creating simple tx: %s", err.Error()) } tx.Input(&tb.Message{Text: "17.3456 USD_TEST"}) // amount + tx.Input(&tb.Message{Text: "Buy something in the grocery store"}) // description tx.Input(&tb.Message{Text: "Assets:Wallet"}) // from tx.Input(&tb.Message{Text: "Expenses:Groceries"}) // to - tx.Input(&tb.Message{Text: "Buy something in the grocery store"}) // description if !tx.IsDone() { t.Errorf("With given input transaction data should be complete for SimpleTx") @@ -210,9 +225,9 @@ func TestTransactionBuildingWithDate(t *testing.T) { t.Errorf("Error creating simple tx: %s", err.Error()) } tx.Input(&tb.Message{Text: "17.3456 USD_TEST"}) // amount + tx.Input(&tb.Message{Text: "Buy something in the grocery store"}) // description tx.Input(&tb.Message{Text: "Assets:Wallet"}) // from tx.Input(&tb.Message{Text: "Expenses:Groceries"}) // to - tx.Input(&tb.Message{Text: "Buy something in the grocery store"}) // description if !tx.IsDone() { t.Errorf("With given input transaction data should be complete for SimpleTx") @@ -228,20 +243,14 @@ func TestTransactionBuildingWithDate(t *testing.T) { `, "Templated string should be filled with variables as expected.") } -func TestCountLeadingDigits(t *testing.T) { - helpers.TestExpect(t, bot.CountLeadingDigits(12.34), 2, "") - helpers.TestExpect(t, bot.CountLeadingDigits(0.34), 1, "") - helpers.TestExpect(t, bot.CountLeadingDigits(1244.0), 4, "") -} - func TestTaggedTransaction(t *testing.T) { tx, _ := bot.CreateSimpleTx("", bot.TEMPLATE_SIMPLE_DEFAULT) tx.SetDate("2021-01-24") log.Print(tx.Debug()) tx.Input(&tb.Message{Text: "17.3456 USD_TEST"}) // amount + tx.Input(&tb.Message{Text: "Buy something"}) // description tx.Input(&tb.Message{Text: "Assets:Wallet"}) // from tx.Input(&tb.Message{Text: "Expenses:Groceries"}) // to - tx.Input(&tb.Message{Text: "Buy something"}) // description template, err := tx.FillTemplate("EUR", "someTag", 0) if err != nil { t.Errorf("Unexpected error: %s", err.Error()) @@ -263,15 +272,16 @@ func TestParseAmount(t *testing.T) { } func TestParseTemplateFields(t *testing.T) { - fields := bot.ParseTemplateFields(`this is a ${description} field, and ${-amount/3}`) + fields := bot.ParseTemplateFields(`this is a ${description} field, and ${-amount/3}`, "") - helpers.TestExpect(t, fields["description"].Name, "description", "description field name") - helpers.TestExpect(t, fields["description"].IsNegative, false, "description not be negative") - helpers.TestExpect(t, fields["description"].Fraction, 1, "description fraction default = 1") + helpers.TestExpect(t, fields[0].Raw, "-amount/3", "amount field raw") + helpers.TestExpect(t, fields[0].FieldName, "amount", "amount field name") + helpers.TestExpect(t, fields[0].IsNegative, true, "amount to be negative") + helpers.TestExpect(t, fields[0].Fraction, 3, "amount fraction") - helpers.TestExpect(t, fields["-amount/3"].Name, "amount", "amount field name") - helpers.TestExpect(t, fields["-amount/3"].IsNegative, true, "amount to be negative") - helpers.TestExpect(t, fields["-amount/3"].Fraction, 3, "amount fraction") + helpers.TestExpect(t, fields[1].FieldName, "description", "description field name") + helpers.TestExpect(t, fields[1].IsNegative, false, "description not be negative") + helpers.TestExpect(t, fields[1].Fraction, 1, "description fraction default = 1") } func dateCase(t *testing.T, given, expected string) { diff --git a/db/crud/bot_cache.go b/db/crud/bot_cache.go index c428a39..efc3b84 100644 --- a/db/crud/bot_cache.go +++ b/db/crud/bot_cache.go @@ -18,26 +18,14 @@ func (r *Repo) PutCacheHints(m *tb.Message, values map[string]string) error { return err } - keyMappings := map[string]string{ - "description": helpers.STX_DESC, - "from": helpers.STX_ACCF, - "to": helpers.STX_ACCT, - } - for key, value := range values { - if keyMappings[key] != "" { - key = keyMappings[key] - } - if !helpers.ArrayContains(helpers.AllowedSuggestionTypes(), key) { - // Don't cache non-suggestible data - continue - } - if helpers.ArrayContains(CACHE_LOCAL[m.Chat.ID][key], value) { + for rawKey, value := range values { + if helpers.ArrayContains(CACHE_LOCAL[m.Chat.ID][helpers.FqCacheKey(rawKey)], value) { // TODO: Update all as single statement _, err = r.db.Exec(` UPDATE "bot::cache" SET "lastUsed" = NOW() WHERE "tgChatId" = $1 AND "type" = $2 AND "value" = $3`, - m.Chat.ID, key, value) + m.Chat.ID, helpers.FqCacheKey(rawKey), value) if err != nil { return err } @@ -46,7 +34,7 @@ func (r *Repo) PutCacheHints(m *tb.Message, values map[string]string) error { _, err = r.db.Exec(` INSERT INTO "bot::cache" ("tgChatId", "type", "value") VALUES ($1, $2, $3)`, - m.Chat.ID, key, value) + m.Chat.ID, helpers.FqCacheKey(rawKey), value) if err != nil { return err } @@ -156,6 +144,17 @@ func (r *Repo) DeleteCacheEntries(m *tb.Message, t string, value string) (sql.Re return res, r.FillCache(m) } +func (r *Repo) DeleteAllCacheEntries(m *tb.Message) error { + _, err := r.db.Exec(` + DELETE FROM "bot::cache" + WHERE "tgChatId" = $1 + `, m.Chat.ID) + if err != nil { + return err + } + return r.FillCache(m) +} + func (r *Repo) CacheUserSettingGetLimits(m *tb.Message) (limits map[string]int, err error) { userSettingPrefix := helpers.USERSET_LIM_PREFIX diff --git a/db/crud/bot_cache_test.go b/db/crud/bot_cache_test.go index 48e7488..7a56e92 100644 --- a/db/crud/bot_cache_test.go +++ b/db/crud/bot_cache_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" + "github.com/LucaBernstein/beancount-bot-tg/bot" "github.com/LucaBernstein/beancount-bot-tg/db/crud" "github.com/LucaBernstein/beancount-bot-tg/helpers" tb "gopkg.in/tucnak/telebot.v2" @@ -27,10 +28,9 @@ func TestCacheOnlySuggestible(t *testing.T) { WHERE "tgChatId" = ?`). WithArgs(chat.ID). WillReturnRows(sqlmock.NewRows([]string{"type", "value"})) - // Should only insert description suggestion into db cache mock. ExpectExec(`INSERT INTO "bot::cache"`). - WithArgs(chat.ID, helpers.STX_DESC, "description_value"). + WithArgs(chat.ID, "description:", "description_value"). WillReturnResult(sqlmock.NewResult(1, 1)) mock. ExpectQuery(` @@ -42,7 +42,14 @@ func TestCacheOnlySuggestible(t *testing.T) { bc := crud.NewRepo(db) message := &tb.Message{Chat: chat} - err = bc.PutCacheHints(message, map[string]string{helpers.STX_DATE: "2021-01-01", helpers.STX_AMTF: "1234", helpers.STX_DESC: "description_value"}) + tx, err := bot.CreateSimpleTx("", "${date} ${amount} ${description}") + if err != nil { + t.Errorf("PutCacheHints unexpectedly threw an error: %s", err.Error()) + } + tx.Input(&tb.Message{Text: "12.34"}) + tx.Input(&tb.Message{Text: "description_value"}) + cacheData := tx.CacheData() + err = bc.PutCacheHints(message, cacheData) if err != nil { t.Errorf("PutCacheHints unexpectedly threw an error: %s", err.Error()) } @@ -68,15 +75,15 @@ func TestSetCacheLimit(t *testing.T) { mock.ExpectBegin() mock. ExpectExec(`DELETE FROM "bot::userSetting"`). - WithArgs(chat.ID, helpers.USERSET_LIM_PREFIX+helpers.STX_DESC). + WithArgs(chat.ID, helpers.USERSET_LIM_PREFIX+"description"). WillReturnResult(sqlmock.NewResult(1, 1)) mock. ExpectExec(`INSERT INTO "bot::userSetting"`). - WithArgs(chat.ID, helpers.USERSET_LIM_PREFIX+helpers.STX_DESC, "23"). + WithArgs(chat.ID, helpers.USERSET_LIM_PREFIX+"description", "23"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() - err = bc.CacheUserSettingSetLimit(message, "txDesc", 23) + err = bc.CacheUserSettingSetLimit(message, "description", 23) if err != nil { t.Errorf("CacheUserSettingSetLimit unexpectedly threw an error: %s", err.Error()) } @@ -84,11 +91,11 @@ func TestSetCacheLimit(t *testing.T) { mock.ExpectBegin() mock. ExpectExec(`DELETE FROM "bot::userSetting"`). - WithArgs(chat.ID, helpers.USERSET_LIM_PREFIX+helpers.STX_DESC). + WithArgs(chat.ID, helpers.USERSET_LIM_PREFIX+"description"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() - err = bc.CacheUserSettingSetLimit(message, "txDesc", -1) + err = bc.CacheUserSettingSetLimit(message, "description", -1) if err != nil { t.Errorf("CacheUserSettingSetLimit (with delete only) unexpectedly threw an error: %s", err.Error()) } @@ -122,16 +129,16 @@ func TestGetCacheLimit(t *testing.T) { mock. ExpectQuery(`SELECT "setting", "value" FROM "bot::userSetting"`). WithArgs(chat.ID, helpers.USERSET_LIM_PREFIX+"%"). - WillReturnRows(sqlmock.NewRows([]string{"setting", "value"}).AddRow(helpers.USERSET_LIM_PREFIX+helpers.STX_DESC, "79")) + WillReturnRows(sqlmock.NewRows([]string{"setting", "value"}).AddRow(helpers.USERSET_LIM_PREFIX+"description", "79")) limits, err := bc.CacheUserSettingGetLimits(message) if err != nil { t.Errorf("TestSetCacheLimitGet unexpectedly threw an error: %s", err.Error()) } - if len(limits) != 3 { - t.Errorf("TestSetCacheLimitGet unexpectedly threw an error") + if len(limits) != 2 { + t.Errorf("TestSetCacheLimitGet limit result was unexpected") } - if limits["txDesc"] != 79 { + if limits["description"] != 79 { t.Errorf("TestSetCacheLimitGet should return values correctly: %d", limits["txDesc"]) } diff --git a/db/crud/monitoring.go b/db/crud/monitoring.go index b4ae9ab..5c5735d 100644 --- a/db/crud/monitoring.go +++ b/db/crud/monitoring.go @@ -86,7 +86,7 @@ func (r *Repo) HealthGetUsersActiveCounts(maxDiffHours int) (count int, err erro return } -func (r *Repo) HealthGetCacheStats() (accTo int, accFrom int, txDesc int, err error) { +func (r *Repo) HealthGetCacheStats() (accTo, accFrom, txDesc, other int, err error) { rows, err := r.db.Query(` SELECT "type", COUNT(*) "c" FROM "bot::cache" @@ -102,12 +102,15 @@ func (r *Repo) HealthGetCacheStats() (accTo int, accFrom int, txDesc int, err er for rows.Next() { rows.Scan(&t, &count) switch t { - case helpers.STX_ACCT: + // TODO: Refactor: Unify all into account + case helpers.FqCacheKey(helpers.FIELD_ACCOUNT + ":" + helpers.FIELD_ACCOUNT_TO): accTo = count - case helpers.STX_ACCF: + case helpers.FqCacheKey(helpers.FIELD_ACCOUNT + ":" + helpers.FIELD_ACCOUNT_FROM): accFrom = count - case helpers.STX_DESC: + case helpers.FqCacheKey(helpers.FIELD_DESCRIPTION): txDesc = count + default: + other += count } } return diff --git a/db/crud/monitoring_test.go b/db/crud/monitoring_test.go index c309e2d..bedc5a4 100644 --- a/db/crud/monitoring_test.go +++ b/db/crud/monitoring_test.go @@ -130,16 +130,18 @@ func TestHealthGetCacheStats(t *testing.T) { mock.ExpectQuery(""). WithArgs(). WillReturnRows(sqlmock.NewRows([]string{"type", "c"}). - AddRow(helpers.STX_ACCT, 7). - AddRow(helpers.STX_ACCF, 9). - AddRow(helpers.STX_DESC, 15)) + AddRow("account:to", 7). + AddRow("account:from", 9). + AddRow("description:", 15). + AddRow("blahbla1:", 21). + AddRow("blahbla2:", 3)) - to, from, desc, err := r.HealthGetCacheStats() + to, from, desc, other, err := r.HealthGetCacheStats() if err != nil { t.Errorf("Should not fail for getting health transactions count: %s", err.Error()) } - if to != 7 || from != 9 || desc != 15 { - t.Errorf("Unexpected cache stats: %d != %d || %d != %d || %d != %d", to, 7, from, 9, desc, 15) + if to != 7 || from != 9 || desc != 15 || other != 24 { + t.Errorf("Unexpected cache stats: %d != %d || %d != %d || %d != %d || %d != %d", to, 7, from, 9, desc, 15, other, 24) } if err := mock.ExpectationsWereMet(); err != nil { diff --git a/db/migrations/controller.go b/db/migrations/controller.go index d4b8e56..8218b0a 100644 --- a/db/migrations/controller.go +++ b/db/migrations/controller.go @@ -21,6 +21,7 @@ func Migrate(db *sql.DB) { migrationWrapper(v8, 8)(db) migrationWrapper(v9, 9)(db) migrationWrapper(v10, 10)(db) + migrationWrapper(v11, 11)(db) helpers.LogLocalf(helpers.INFO, nil, "Migrations ran through. Schema version: %d", schema(db)) } diff --git a/db/migrations/v11.go b/db/migrations/v11.go new file mode 100644 index 0000000..c50ce07 --- /dev/null +++ b/db/migrations/v11.go @@ -0,0 +1,96 @@ +package migrations + +import ( + "database/sql" + "log" +) + +func v11(db *sql.Tx) { + v11ExtendAccountAndDescriptionHintsInSuggestions(db) + v11ExtendAccountHintsInExistingTemplates(db) + v11RemoveUserSettingTypesLimits(db) +} + +func v11ExtendAccountAndDescriptionHintsInSuggestions(db *sql.Tx) { + sqlStatement := ` + UPDATE "bot::cache" + SET "type" = 'account:from' + WHERE "type" = 'accFrom'; + ` + _, err := db.Exec(sqlStatement) + if err != nil { + log.Fatal(err) + } + + sqlStatement = ` + UPDATE "bot::cache" + SET "type" = 'account:to' + WHERE "type" = 'accTo'; + ` + _, err = db.Exec(sqlStatement) + if err != nil { + log.Fatal(err) + } + + sqlStatement = ` + UPDATE "bot::cache" + SET "type" = 'description:' + WHERE "type" = 'txDesc'; + ` + _, err = db.Exec(sqlStatement) + if err != nil { + log.Fatal(err) + } +} + +func v11ExtendAccountHintsInExistingTemplates(db *sql.Tx) { + sqlStatement := ` + UPDATE "bot::template" + SET "template" = REPLACE("template", '${from}', '${account:from}') + WHERE "template" LIKE '%${from}%'; + ` + _, err := db.Exec(sqlStatement) + if err != nil { + log.Fatal(err) + } + + sqlStatement = ` + UPDATE "bot::template" + SET "template" = REPLACE("template", '${to}', '${account:to}') + WHERE "template" LIKE '%${to}%'; + ` + _, err = db.Exec(sqlStatement) + if err != nil { + log.Fatal(err) + } +} + +func v11RemoveUserSettingTypesLimits(db *sql.Tx) { + sqlStatement := ` + DELETE FROM "bot::userSetting" + WHERE "setting" LIKE 'user.limitCache.%'; + ` + _, err := db.Exec(sqlStatement) + if err != nil { + log.Fatal(err) + } + + sqlStatement = ` + DELETE FROM "bot::userSettingTypes" + WHERE "setting" LIKE 'user.limitCache.%'; + ` + _, err = db.Exec(sqlStatement) + if err != nil { + log.Fatal(err) + } + + sqlStatement = ` + INSERT INTO "bot::userSettingTypes" + ("setting", "description") + VALUES ('user.limitCache', 'limit cached value count for transactions. Array by type.');; + ` + _, err = db.Exec(sqlStatement) + if err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod index 399d512..716f13e 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + github.com/fatih/structs v1.1.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect ) diff --git a/go.sum b/go.sum index e5a689c..0c4ab13 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/go-co-op/gocron v1.13.0 h1:BjkuNImPy5NuIPEifhWItFG7pYyr27cyjS6BN9w/D4c= github.com/go-co-op/gocron v1.13.0/go.mod h1:GD5EIEly1YNW+LovFVx5dzbYVcIc8544K99D8UVRpGo= github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ= diff --git a/helpers/arrays_test.go b/helpers/arrays_test.go index 15a1c2d..5d610e7 100644 --- a/helpers/arrays_test.go +++ b/helpers/arrays_test.go @@ -22,6 +22,4 @@ func TestArrayMatching(t *testing.T) { if helpers.ArraysEqual([]string{"a"}, []string{"b"}) { t.Error("ArraysEqual should fail for different arrays") } - - // TODO: ArraysEqual 83.3% } diff --git a/helpers/constants.go b/helpers/constants.go index 69d1195..23655a7 100644 --- a/helpers/constants.go +++ b/helpers/constants.go @@ -1,11 +1,16 @@ package helpers +import "strings" + const ( - STX_DESC = "txDesc" - STX_DATE = "txDate" - STX_ACCF = "accFrom" - STX_AMTF = "amountFrom" - STX_ACCT = "accTo" + FIELD_DATE = "date" + FIELD_DESCRIPTION = "description" + FIELD_AMOUNT = "amount" + FIELD_ACCOUNT = "account" + FIELD_TAG = "tag" + + FIELD_ACCOUNT_FROM = "from" + FIELD_ACCOUNT_TO = "to" DOT_INDENT = 47 @@ -24,8 +29,19 @@ const ( func AllowedSuggestionTypes() []string { return []string{ - STX_ACCF, - STX_ACCT, - STX_DESC, + FIELD_DESCRIPTION, + FIELD_ACCOUNT, + } +} + +func TypeCacheKey(key string) string { + return strings.SplitN(key, ":", 2)[0] +} + +func FqCacheKey(key string) string { + if !strings.Contains(key, ":") { + return key + ":" } + splits := strings.SplitN(key, ":", 3) + return splits[0] + ":" + splits[1] } diff --git a/helpers/constants_test.go b/helpers/constants_test.go index 6f8f020..410aac6 100644 --- a/helpers/constants_test.go +++ b/helpers/constants_test.go @@ -6,9 +6,10 @@ import ( "github.com/LucaBernstein/beancount-bot-tg/helpers" ) -func TestAllowedSuggestionTypes(t *testing.T) { - types := helpers.AllowedSuggestionTypes() - if !helpers.ArrayContains(types, helpers.STX_ACCT) { - t.Errorf("Allowed suggestion types did not contain %s", helpers.STX_ACCT) - } +func TestFqCacheKey(t *testing.T) { + helpers.TestExpect(t, helpers.FqCacheKey("desc"), "desc:", "") + helpers.TestExpect(t, helpers.FqCacheKey("desc:"), "desc:", "") + helpers.TestExpect(t, helpers.FqCacheKey("desc:test"), "desc:test", "") + helpers.TestExpect(t, helpers.FqCacheKey("desc:test:"), "desc:test", "") + helpers.TestExpect(t, helpers.FqCacheKey("desc:test:abc"), "desc:test", "") } diff --git a/scenarioTests/features/health.feature b/scenarioTests/features/health.feature index 224f209..c808fd2 100644 --- a/scenarioTests/features/health.feature +++ b/scenarioTests/features/health.feature @@ -3,7 +3,7 @@ Feature: Health Endpoint Scenario: Recently active users Given I have a bot When I send the message "/help" - And I wait 0.2 seconds + And I wait 0.4 seconds And I get the server endpoint "/health" Then the response body should include "bc_bot_users_active_last{timeframe="1h"}" But the response body should not include "bc_bot_users_active_last{timeframe="1h"} 0" @@ -14,10 +14,10 @@ Feature: Health Endpoint Given I have a bot When I send the message "/cancel" And I send the message "/simple" - And I wait 0.2 seconds + And I wait 0.4 seconds And I get the server endpoint "/health" Then the response body should include "bc_bot_tx_states_count 1" When I send the message "/cancel" - And I wait 0.2 seconds + And I wait 0.4 seconds And I get the server endpoint "/health" Then the response body should include "bc_bot_tx_states_count 0" diff --git a/scenarioTests/features/keyboard.feature b/scenarioTests/features/keyboard.feature index 50f744a..0920bb0 100644 --- a/scenarioTests/features/keyboard.feature +++ b/scenarioTests/features/keyboard.feature @@ -2,13 +2,16 @@ Feature: Bot suggestions keyboard Scenario: Suggest last used values Given I have a bot - When I send the message "/suggestions rm accFrom" - And I wait 0.2 seconds - And I send the message "/suggestions add accFrom fromAccount" - And I wait 0.2 seconds + When I send the message "/suggestions rm account:from" + And I wait 0.1 seconds + And I send the message "/suggestions add account:from fromAccount" + And I wait 0.1 seconds When I send the message "1.00" + And I wait 0.2 seconds Then 2 messages should be sent back And the response should include the message "Automatically created a new transaction for you" + When I send the message "unimportant_description" + Then 1 messages should be sent back And the response should have a keyboard with the first entry being "fromAccount" When I send the message "/cancel" @@ -16,16 +19,18 @@ Feature: Bot suggestions keyboard Given I have a bot When I send the message "/deleteAll yes" And I wait 0.2 seconds - And I send the message "/suggestions rm accFrom" + And I send the message "/suggestions rm account:from" And I wait 0.2 seconds - And I send the message "/suggestions add accFrom fromAccount" + And I send the message "/suggestions add account:from fromAccount" And I wait 0.2 seconds - And I create a simple tx with amount 1.23 and accFrom someFromAccount and accTo someToAccount and desc Test Tx + And I create a simple tx with amount 1.23 and desc Test Tx and account:from someFromAccount and account:to someToAccount And I send the message "/list" Then 1 messages should be sent back And the response should include the message " someFromAccount -1.23 EUR" When I send the message "1.00" Then 2 messages should be sent back And the response should include the message "Automatically created a new transaction for you" + When I send the message "unimportant_description" + Then 1 messages should be sent back And the response should have a keyboard with the first entry being "someFromAccount" When I send the message "/cancel" diff --git a/scenarioTests/features/list.feature b/scenarioTests/features/list.feature index 782f05d..2122c3b 100644 --- a/scenarioTests/features/list.feature +++ b/scenarioTests/features/list.feature @@ -3,18 +3,21 @@ Feature: List transactions Scenario: List Given I have a bot When I send the message "/deleteAll yes" - And I wait 0.2 seconds + And I wait 0.1 seconds When I send the message "/list" + And I wait 0.1 seconds Then 1 messages should be sent back And the response should include the message "You might also be looking for archived transactions using '/list archived'." - When I create a simple tx with amount 1.23 and accFrom someFromAccount and accTo someToAccount and desc Test Tx + When I create a simple tx with amount 1.23 and desc Test Tx and account:from someFromAccount and account:to someToAccount And I wait 0.1 seconds When I send the message "/list" + And I wait 0.1 seconds Then 1 messages should be sent back And the response should include the message "-1.23 EUR" When I send the message "/archiveAll" - And I wait 0.2 seconds + And I wait 0.1 seconds And I send the message "/list" + And I wait 0.1 seconds Then 1 messages should be sent back And the response should include the message "You might also be looking for archived transactions using '/list archived'." @@ -22,7 +25,7 @@ Feature: List transactions Given I have a bot When I send the message "/deleteAll yes" And I wait 0.2 seconds - And I create a simple tx with amount 1.23 and accFrom someFromAccount and accTo someToAccount and desc Test Tx + And I create a simple tx with amount 1.23 and desc Test Tx and account:from someFromAccount and account:to someToAccount And I wait 0.1 seconds When I send the message "/list dated" And I wait 0.1 seconds @@ -32,18 +35,21 @@ Feature: List transactions Scenario: List archived Given I have a bot When I send the message "/deleteAll yes" - And I wait 0.2 seconds + And I wait 0.1 seconds When I send the message "/list archived" + And I wait 0.1 seconds Then 1 messages should be sent back And the response should include the message "You might also be looking for transactions using '/list'." - When I create a simple tx with amount 1.23 and accFrom someFromAccount and accTo someToAccount and desc Test Tx + When I create a simple tx with amount 1.23 and desc Test Tx and account:from someFromAccount and account:to someToAccount And I wait 0.1 seconds When I send the message "/list archived" + And I wait 0.1 seconds Then 1 messages should be sent back And the response should include the message "You might also be looking for transactions using '/list'." When I send the message "/archiveAll" - And I wait 0.2 seconds + And I wait 0.1 seconds And I send the message "/list archived" + And I wait 0.1 seconds Then 1 messages should be sent back And the response should include the message "-1.23 EUR" @@ -51,15 +57,17 @@ Feature: List transactions Given I have a bot When I send the message "/deleteAll yes" And I wait 0.2 seconds - And I create a simple tx with amount 1.23 and accFrom someFromAccount and accTo someToAccount and desc Test Tx + And I create a simple tx with amount 1.23 and desc Test Tx and account:from someFromAccount and account:to someToAccount And I wait 0.1 seconds - And I create a simple tx with amount 1.23 and accFrom someFromAccount and accTo someToAccount and desc Another tx + And I create a simple tx with amount 1.23 and desc Another tx and account:from someFromAccount and account:to someToAccount And I wait 0.1 seconds When I send the message "/list numbered" + And I wait 0.1 seconds Then 1 messages should be sent back And the response should include the message "1) $today * "Test Tx"" And the same response should include the message "2) $today * "Another tx"" When I send the message "/list rm 1" + And I wait 0.1 seconds Then 1 messages should be sent back And the response should include the message "Successfully deleted the list entry specified" When I send the message "/list numbered" @@ -67,5 +75,6 @@ Feature: List transactions Then 1 messages should be sent back And the response should include the message "1) $today * "Another tx"" When I send the message "/list rm 15" + And I wait 0.1 seconds Then 1 messages should be sent back And the response should include the message "the number you specified was too high" diff --git a/scenarioTests/features/templates.feature b/scenarioTests/features/templates.feature index 9bd18d2..3850fd1 100644 --- a/scenarioTests/features/templates.feature +++ b/scenarioTests/features/templates.feature @@ -14,7 +14,7 @@ Feature: Templates When I send the message "/t my" Then 2 messages should be sent back And the response should include the message "Creating a new transaction from your template 'mytpl'" - And the response should include the message "Please enter the *amount*" + And the response should include the message "Please enter the **amount**" When I send the message "15,15" Then 1 messages should be sent back And the response should include the message "Please enter a **description**" @@ -23,7 +23,7 @@ Feature: Templates And the response should include the message "Successfully recorded your transaction." When I send the message "/list" Then 1 messages should be sent back - And the response should include the message "some description weird template -5.05 EUR" + And the response should include the message " some description weird template -5.05 EUR" When I send the message "/t rm mytpl" Then 1 messages should be sent back And the response should include the message "Successfully removed your template 'mytpl'" diff --git a/scenarioTests/features/transactions.feature b/scenarioTests/features/transactions.feature index dadb0e7..fc65be1 100644 --- a/scenarioTests/features/transactions.feature +++ b/scenarioTests/features/transactions.feature @@ -19,14 +19,14 @@ Feature: Transactions When I send the message "12.34" Then 2 messages should be sent back And the response should include the message "created a new transaction for you" + And the response should include the message "enter a **description**" + When I send the message "any random tx description" + Then 1 messages should be sent back And the response should include the message "enter the **account** the money came **from**" When I send the message "FromAccount" Then 1 messages should be sent back And the response should include the message "enter the **account** the money went **to**" When I send the message "ToAccount" - Then 1 messages should be sent back - And the response should include the message "enter a **description**" - When I send the message "any random tx description" Then 1 messages should be sent back And the response should include the message "Successfully recorded your transaction." When I send the message "/list" diff --git a/scenarioTests/steps/bot.py b/scenarioTests/steps/bot.py index bc4ffa4..29c3f14 100644 --- a/scenarioTests/steps/bot.py +++ b/scenarioTests/steps/bot.py @@ -155,9 +155,9 @@ async def step_impl(context, shouldShouldNot): print("expected response", expectedResponse, "did not match actual response", response) assert False -@when('I create a simple tx with amount {amount} and accFrom {accFrom} and accTo {accTo} and desc {desc}') +@when('I create a simple tx with amount {amount} and desc {desc} and account:from {account_from} and account:to {account_to}') @async_run_until_complete -async def step_impl(context, amount, accFrom, accTo, desc): - for command in ["/cancel", amount, accFrom, accTo, desc]: +async def step_impl(context, amount, desc, account_from, account_to): + for command in ["/cancel", amount, desc, account_from, account_to]: context.offsetId = (await bot_send_message(context.chat, context.testChatId, command)).id await wait_seconds(0.1) diff --git a/web/health/monitoring.go b/web/health/monitoring.go index 8a9743b..cef4abe 100644 --- a/web/health/monitoring.go +++ b/web/health/monitoring.go @@ -26,6 +26,7 @@ type MonitoringResult struct { cache_entries_accTo int cache_entries_accFrom int cache_entries_txDesc int + cache_entries_other int tx_states_count int @@ -64,6 +65,7 @@ bc_bot_users_active_last{timeframe="7d"} %d bc_bot_cache_entries{type="accTo"} %d bc_bot_cache_entries{type="accFrom"} %d bc_bot_cache_entries{type="txDesc"} %d +bc_bot_cache_entries{type="other"} %d # HELP bc_bot_tx_states_count Count of users with open transactions # TYPE bc_bot_tx_states_count gauge @@ -89,6 +91,7 @@ bc_bot_version_information{version="%s"} 1 m.cache_entries_accTo, m.cache_entries_accFrom, m.cache_entries_txDesc, + m.cache_entries_other, m.tx_states_count, @@ -141,13 +144,14 @@ func gatherMetrics(bc *bot.BotController) (result *MonitoringResult) { } result.users_active_last_7d = active_7d - accTo, accFrom, txDesc, err := bc.Repo.HealthGetCacheStats() + accTo, accFrom, txDesc, other, err := bc.Repo.HealthGetCacheStats() if err != nil { bc.Logf(helpers.ERROR, nil, "Error getting health transactions: %s", err.Error()) } result.cache_entries_accTo = accTo result.cache_entries_accFrom = accFrom result.cache_entries_txDesc = txDesc + result.cache_entries_other = other result.tx_states_count = bc.State.CountOpen()