From 43680000a8acaaf874a8c42c229b7d581caa076f Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 24 Mar 2022 08:49:09 +1100 Subject: [PATCH] sqs-browse: started working on tests for controllers --- go.mod | 2 +- internal/common/ui/dispatcher/context.go | 21 +-- internal/common/ui/dispatcher/dispatcher.go | 2 +- internal/common/ui/events/errors.go | 1 + internal/dynamo-browse/controllers/events.go | 4 + internal/dynamo-browse/controllers/state.go | 27 +++ .../dynamo-browse/controllers/tablewrite.go | 24 ++- .../controllers/tablewrite_test.go | 165 ++++++++++++++++++ internal/dynamo-browse/models/models.go | 1 + .../dynamo-browse/services/tables/service.go | 1 + .../services/tables/service_test.go | 1 + internal/dynamo-browse/ui/model.go | 60 +++++-- internal/sqs-browse/ui/model.go | 14 +- test/testuictx/testuictx.go | 21 +++ 14 files changed, 305 insertions(+), 39 deletions(-) create mode 100644 internal/dynamo-browse/controllers/state.go create mode 100644 internal/dynamo-browse/controllers/tablewrite_test.go create mode 100644 test/testuictx/testuictx.go diff --git a/go.mod b/go.mod index 81ae3e4..1a3679c 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.17 require ( github.com/aws/aws-sdk-go-v2 v1.15.0 github.com/aws/aws-sdk-go-v2/config v1.13.1 + github.com/aws/aws-sdk-go-v2/credentials v1.8.0 github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.8.0 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.0 github.com/aws/aws-sdk-go-v2/service/sqs v1.16.0 @@ -20,7 +21,6 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.8.0 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.6 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0 // indirect diff --git a/internal/common/ui/dispatcher/context.go b/internal/common/ui/dispatcher/context.go index 181128e..19b1a50 100644 --- a/internal/common/ui/dispatcher/context.go +++ b/internal/common/ui/dispatcher/context.go @@ -7,24 +7,25 @@ import ( "github.com/lmika/awstools/internal/common/ui/uimodels" ) -type dispatcherContext struct { - d *Dispatcher +type DispatcherContext struct { + Publisher MessagePublisher } -func (dc dispatcherContext) Messagef(format string, args ...interface{}) { - dc.d.publisher.Send(events.Message(fmt.Sprintf(format, args...))) +func (dc DispatcherContext) Messagef(format string, args ...interface{}) { + dc.Publisher.Send(events.Message(fmt.Sprintf(format, args...))) } -func (dc dispatcherContext) Send(teaMessage tea.Msg) { - dc.d.publisher.Send(teaMessage) +func (dc DispatcherContext) Send(teaMessage tea.Msg) { + dc.Publisher.Send(teaMessage) } -func (dc dispatcherContext) Message(msg string) { - dc.d.publisher.Send(events.Message(msg)) +func (dc DispatcherContext) Message(msg string) { + dc.Publisher.Send(events.Message(msg)) } -func (dc dispatcherContext) Input(prompt string, onDone uimodels.Operation) { - dc.d.publisher.Send(events.PromptForInput{ +func (dc DispatcherContext) Input(prompt string, onDone uimodels.Operation) { + dc.Publisher.Send(events.PromptForInput{ + Prompt: prompt, OnDone: onDone, }) } diff --git a/internal/common/ui/dispatcher/dispatcher.go b/internal/common/ui/dispatcher/dispatcher.go index e126b4d..0693143 100644 --- a/internal/common/ui/dispatcher/dispatcher.go +++ b/internal/common/ui/dispatcher/dispatcher.go @@ -32,7 +32,7 @@ func (d *Dispatcher) Start(ctx context.Context, operation uimodels.Operation) { d.runningOp = operation go func() { - subCtx := uimodels.WithContext(ctx, dispatcherContext{d}) + subCtx := uimodels.WithContext(ctx, DispatcherContext{d.publisher}) err := operation.Execute(subCtx) if err != nil { diff --git a/internal/common/ui/events/errors.go b/internal/common/ui/events/errors.go index 2f96a3f..35fe5be 100644 --- a/internal/common/ui/events/errors.go +++ b/internal/common/ui/events/errors.go @@ -12,5 +12,6 @@ type Message string // PromptForInput indicates that the context is requesting a line of input type PromptForInput struct { + Prompt string OnDone uimodels.Operation } \ No newline at end of file diff --git a/internal/dynamo-browse/controllers/events.go b/internal/dynamo-browse/controllers/events.go index 6f51b9a..f911744 100644 --- a/internal/dynamo-browse/controllers/events.go +++ b/internal/dynamo-browse/controllers/events.go @@ -5,3 +5,7 @@ import "github.com/lmika/awstools/internal/dynamo-browse/models" type NewResultSet struct { ResultSet *models.ResultSet } + +type SetReadWrite struct { + NewValue bool +} \ No newline at end of file diff --git a/internal/dynamo-browse/controllers/state.go b/internal/dynamo-browse/controllers/state.go new file mode 100644 index 0000000..db2174b --- /dev/null +++ b/internal/dynamo-browse/controllers/state.go @@ -0,0 +1,27 @@ +package controllers + +import ( + "context" + "github.com/lmika/awstools/internal/dynamo-browse/models" +) + +type State struct { + ResultSet *models.ResultSet + SelectedItem models.Item + + // InReadWriteMode indicates whether modifications can be made to the table + InReadWriteMode bool +} + +type stateContextKeyType struct{} + +var stateContextKey = stateContextKeyType{} + +func CurrentState(ctx context.Context) State { + state, _ := ctx.Value(stateContextKey).(State) + return state +} + +func ContextWithState(ctx context.Context, state State) context.Context { + return context.WithValue(ctx, stateContextKey, state) +} diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 1482448..c67cb23 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -4,7 +4,6 @@ import ( "context" "github.com/lmika/awstools/internal/common/ui/uimodels" "github.com/lmika/awstools/internal/dynamo-browse/services/tables" - "github.com/lmika/awstools/internal/dynamo-browse/models" "github.com/pkg/errors" ) @@ -22,13 +21,28 @@ func NewTableWriteController(tableService *tables.Service, tableReadControllers } } -func (c *TableWriteController) Delete(item models.Item) uimodels.Operation { +func (c *TableWriteController) EnableReadWrite() uimodels.Operation { return uimodels.OperationFn(func(ctx context.Context) error { uiCtx := uimodels.Ctx(ctx) + uiCtx.Send(SetReadWrite{NewValue: true}) + uiCtx.Message("read/write mode enabled") - // TODO: only do if rw mode enabled + return nil + }) +} - uiCtx.Input("Delete item?", uimodels.OperationFn(func(ctx context.Context) error { +func (c *TableWriteController) Delete() uimodels.Operation { + return uimodels.OperationFn(func(ctx context.Context) error { + uiCtx := uimodels.Ctx(ctx) + state := CurrentState(ctx) + + if state.SelectedItem == nil { + return errors.New("no selected item") + } else if !state.InReadWriteMode { + return errors.New("not in read/write mode") + } + + uiCtx.Input("Delete item? ", uimodels.OperationFn(func(ctx context.Context) error { uiCtx := uimodels.Ctx(ctx) if uimodels.PromptValue(ctx) != "y" { @@ -36,7 +50,7 @@ func (c *TableWriteController) Delete(item models.Item) uimodels.Operation { } // Delete the item - if err := c.tableService.Delete(ctx, c.tableName, item); err != nil { + if err := c.tableService.Delete(ctx, c.tableName, state.SelectedItem); err != nil { return err } diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go new file mode 100644 index 0000000..ed549f7 --- /dev/null +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -0,0 +1,165 @@ +package controllers_test + +import ( + "context" + "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/awstools/internal/dynamo-browse/controllers" + "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/awstools/internal/dynamo-browse/services/tables" + "github.com/lmika/awstools/test/testdynamo" + "github.com/lmika/awstools/test/testuictx" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestTableWriteController_EnableReadWrite(t *testing.T) { + twc, _, closeFn := setupController(t) + t.Cleanup(closeFn) + + t.Run("should send event enabling read write", func(t *testing.T) { + ctx, uiCtx := testuictx.New(context.Background()) + ctx = controllers.ContextWithState(ctx, controllers.State{ + InReadWriteMode: false, + }) + + err := twc.EnableReadWrite().Execute(ctx) + assert.NoError(t, err) + + assert.Contains(t, uiCtx.Messages, controllers.SetReadWrite{NewValue: true}) + }) +} + +func TestTableWriteController_Delete(t *testing.T) { + t.Run("should delete selected item if in read/write mode is inactive", func(t *testing.T) { + twc, ctrls, closeFn := setupController(t) + t.Cleanup(closeFn) + + resultSet, err := ctrls.tableService.Scan(context.Background(), ctrls.tableName) + assert.NoError(t, err) + assert.Len(t, resultSet.Items, 3) + + ctx, uiCtx := testuictx.New(context.Background()) + ctx = controllers.ContextWithState(ctx, controllers.State{ + ResultSet: resultSet, + SelectedItem: resultSet.Items[1], + InReadWriteMode: false, + }) + + op := twc.Delete() + + // Should prompt first + err = op.Execute(ctx) + assert.NoError(t, err) + + promptRequest, ok := uiCtx.Messages[0].(events.PromptForInput) + assert.True(t, ok) + + // After prompt, continue to delete + err = promptRequest.OnDone.Execute(uimodels.WithPromptValue(ctx, "y")) + assert.NoError(t, err) + + afterResultSet, err := ctrls.tableService.Scan(context.Background(), ctrls.tableName) + assert.NoError(t, err) + assert.Len(t, afterResultSet.Items, 2) + assert.Contains(t, afterResultSet.Items, resultSet.Items[0]) + assert.NotContains(t, afterResultSet.Items, resultSet.Items[1]) + assert.Contains(t, afterResultSet.Items, resultSet.Items[2]) + }) + + t.Run("should not delete selected item if prompt is not y", func(t *testing.T) { + twc, ctrls, closeFn := setupController(t) + t.Cleanup(closeFn) + + resultSet, err := ctrls.tableService.Scan(context.Background(), ctrls.tableName) + assert.NoError(t, err) + assert.Len(t, resultSet.Items, 3) + + ctx, uiCtx := testuictx.New(context.Background()) + ctx = controllers.ContextWithState(ctx, controllers.State{ + ResultSet: resultSet, + SelectedItem: resultSet.Items[1], + InReadWriteMode: false, + }) + + op := twc.Delete() + + // Should prompt first + err = op.Execute(ctx) + assert.NoError(t, err) + + promptRequest, ok := uiCtx.Messages[0].(events.PromptForInput) + assert.True(t, ok) + + // After prompt, continue to delete + err = promptRequest.OnDone.Execute(uimodels.WithPromptValue(ctx, "n")) + assert.Error(t, err) + + afterResultSet, err := ctrls.tableService.Scan(context.Background(), ctrls.tableName) + assert.NoError(t, err) + assert.Len(t, afterResultSet.Items, 3) + assert.Contains(t, afterResultSet.Items, resultSet.Items[0]) + assert.Contains(t, afterResultSet.Items, resultSet.Items[1]) + assert.Contains(t, afterResultSet.Items, resultSet.Items[2]) + }) + + t.Run("should not delete if read/write mode is inactive", func(t *testing.T) { + tableWriteController, ctrls, closeFn := setupController(t) + t.Cleanup(closeFn) + + resultSet, err := ctrls.tableService.Scan(context.Background(), ctrls.tableName) + assert.NoError(t, err) + assert.Len(t, resultSet.Items, 3) + + ctx, _ := testuictx.New(context.Background()) + ctx = controllers.ContextWithState(ctx, controllers.State{ + ResultSet: resultSet, + SelectedItem: resultSet.Items[1], + InReadWriteMode: false, + }) + + op := tableWriteController.Delete() + + err = op.Execute(ctx) + assert.Error(t, err) + }) +} + +type controller struct { + tableName string + tableService *tables.Service +} + +func setupController(t *testing.T) (*controllers.TableWriteController, controller, func()) { + tableName := "table-write-controller-table" + + client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + provider := dynamo.NewProvider(client) + tableService := tables.NewService(provider) + tableReadController := controllers.NewTableReadController(tableService, tableName) + tableWriteController := controllers.NewTableWriteController(tableService, tableReadController, tableName) + return tableWriteController, controller{ + tableName: tableName, + tableService: tableService, + }, cleanupFn +} + +var testData = testdynamo.TestData{ + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "abc", + "sk": "111", + "alpha": "This is some value", + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, +} \ No newline at end of file diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index 3bb2762..a3ad1bd 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -3,6 +3,7 @@ package models import "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" type ResultSet struct { + Table string Columns []string Items []Item } diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go index a3068c1..dbcc3c5 100644 --- a/internal/dynamo-browse/services/tables/service.go +++ b/internal/dynamo-browse/services/tables/service.go @@ -54,6 +54,7 @@ func (s *Service) Scan(ctx context.Context, table string) (*models.ResultSet, er models.Sort(results, pk, sk) return &models.ResultSet{ + Table: table, Columns: columns, Items: results, }, nil diff --git a/internal/dynamo-browse/services/tables/service_test.go b/internal/dynamo-browse/services/tables/service_test.go index c491fbe..c79e1ca 100644 --- a/internal/dynamo-browse/services/tables/service_test.go +++ b/internal/dynamo-browse/services/tables/service_test.go @@ -24,6 +24,7 @@ func TestService_Scan(t *testing.T) { assert.NoError(t, err) // Hash first, then range, then columns in alphabetic order + assert.Equal(t, rs.Table, tableName) assert.Equal(t, rs.Columns, []string{"pk", "sk", "alpha", "beta", "gamma"}) assert.Equal(t, rs.Items[0], testdynamo.TestRecordAsItem(t, testData[1])) assert.Equal(t, rs.Items[1], testdynamo.TestRecordAsItem(t, testData[0])) diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 9e1549b..90ad0da 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -13,16 +13,19 @@ import ( "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/common/ui/uimodels" "github.com/lmika/awstools/internal/dynamo-browse/controllers" - "github.com/lmika/awstools/internal/dynamo-browse/models" "strings" "text/tabwriter" ) var ( - headerStyle = lipgloss.NewStyle(). + activeHeaderStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#ffffff")). Background(lipgloss.Color("#4479ff")) + + inactiveHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")) ) type uiModel struct { @@ -32,7 +35,8 @@ type uiModel struct { tableWidth, tableHeight int ready bool - resultSet *models.ResultSet + //resultSet *models.ResultSet + state controllers.State message string pendingInput *events.PromptForInput @@ -72,10 +76,11 @@ func (m *uiModel) updateTable() { return } - newTbl := table.New(m.resultSet.Columns, m.tableWidth, m.tableHeight) - newRows := make([]table.Row, len(m.resultSet.Items)) - for i, r := range m.resultSet.Items { - newRows[i] = itemTableRow{m.resultSet, r} + resultSet := m.state.ResultSet + newTbl := table.New(resultSet.Columns, m.tableWidth, m.tableHeight) + newRows := make([]table.Row, len(resultSet.Items)) + for i, r := range resultSet.Items { + newRows[i] = itemTableRow{resultSet, r} } newTbl.SetRows(newRows) @@ -83,7 +88,8 @@ func (m *uiModel) updateTable() { } func (m *uiModel) selectedItem() (itemTableRow, bool) { - if m.ready && m.resultSet != nil && len(m.resultSet.Items) > 0 { + resultSet := m.state.ResultSet + if m.ready && resultSet != nil && len(resultSet.Items) > 0 { selectedItem, ok := m.table.SelectedRow().(itemTableRow) if ok { return selectedItem, true @@ -126,9 +132,11 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Local events case controllers.NewResultSet: - m.resultSet = msg.ResultSet + m.state.ResultSet = msg.ResultSet m.updateTable() m.updateViewportToSelectedMessage() + case controllers.SetReadWrite: + m.state.InReadWriteMode = msg.NewValue // Shared events case events.Error: @@ -136,6 +144,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case events.Message: m.message = string(msg) case events.PromptForInput: + m.textInput.Prompt = msg.Prompt m.textInput.Focus() m.textInput.SetValue("") m.pendingInput = &msg @@ -185,11 +194,11 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // TODO: these should be moved somewhere else case "s": - m.dispatcher.Start(context.Background(), m.tableReadController.Scan()) + m.invokeOperation(m.tableReadController.Scan()) case "D": - if selectedItem, ok := m.selectedItem(); ok { - m.dispatcher.Start(context.Background(), m.tableWriteController.Delete(selectedItem.item)) - } + m.invokeOperation(m.tableWriteController.Delete()) + case "w": + m.invokeOperation(m.tableWriteController.EnableReadWrite()) } default: m.textInput, textInputCommands = m.textInput.Update(msg) @@ -204,6 +213,16 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(textInputCommands, tableMsgs, viewportMsgs) } +func (m uiModel) invokeOperation(op uimodels.Operation) { + state := m.state + if selectedItem, ok := m.selectedItem(); ok { + state.SelectedItem = selectedItem.item + } + + ctx := controllers.ContextWithState(context.Background(), state) + m.dispatcher.Start(ctx, op) +} + func (m uiModel) View() string { if !m.ready { return "Initializing" @@ -229,14 +248,21 @@ func (m uiModel) View() string { } func (m uiModel) headerView() string { - title := headerStyle.Render("Table: XXX") - line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) + var titleText string + if m.state.ResultSet != nil { + titleText = "Table: " + m.state.ResultSet.Table + } else { + titleText = "No table" + } + + title := activeHeaderStyle.Render(titleText) + line := activeHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) return lipgloss.JoinHorizontal(lipgloss.Left, title, line) } func (m uiModel) splitterView() string { - title := headerStyle.Render("Item") - line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) + title := inactiveHeaderStyle.Render("Item") + line := inactiveHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) return lipgloss.JoinHorizontal(lipgloss.Left, title, line) } diff --git a/internal/sqs-browse/ui/model.go b/internal/sqs-browse/ui/model.go index eb6cc07..7f42f3e 100644 --- a/internal/sqs-browse/ui/model.go +++ b/internal/sqs-browse/ui/model.go @@ -17,10 +17,14 @@ import ( ) var ( - headerStyle = lipgloss.NewStyle(). + activeHeaderStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#ffffff")). Background(lipgloss.Color("#eac610")) + + inactiveHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")) ) type uiModel struct { @@ -194,14 +198,14 @@ func (m uiModel) View() string { } func (m uiModel) headerView() string { - title := headerStyle.Render("Queue: XXX") - line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) + title := activeHeaderStyle.Render("Queue: XXX") + line := activeHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) return lipgloss.JoinHorizontal(lipgloss.Left, title, line) } func (m uiModel) splitterView() string { - title := headerStyle.Render("Message") - line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) + title := activeHeaderStyle.Render("Message") + line := activeHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) return lipgloss.JoinHorizontal(lipgloss.Left, title, line) } diff --git a/test/testuictx/testuictx.go b/test/testuictx/testuictx.go new file mode 100644 index 0000000..2ef9c22 --- /dev/null +++ b/test/testuictx/testuictx.go @@ -0,0 +1,21 @@ +package testuictx + +import ( + "context" + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/common/ui/dispatcher" + "github.com/lmika/awstools/internal/common/ui/uimodels" +) + +func New(ctx context.Context) (context.Context, *TestUIContext) { + td := &TestUIContext{} + return uimodels.WithContext(ctx, dispatcher.DispatcherContext{td}), td +} + +type TestUIContext struct { + Messages []tea.Msg +} + +func (t *TestUIContext) Send(msg tea.Msg) { + t.Messages = append(t.Messages, msg) +}