sqs-browse: started working on tests for controllers

This commit is contained in:
Leon Mika 2022-03-24 08:49:09 +11:00
parent fb749aaee2
commit 43680000a8
14 changed files with 305 additions and 39 deletions

2
go.mod
View file

@ -5,6 +5,7 @@ go 1.17
require ( require (
github.com/aws/aws-sdk-go-v2 v1.15.0 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/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/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/dynamodb v1.15.0
github.com/aws/aws-sdk-go-v2/service/sqs v1.16.0 github.com/aws/aws-sdk-go-v2/service/sqs v1.16.0
@ -20,7 +21,6 @@ require (
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect 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/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/configsources v1.1.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0 // indirect

View file

@ -7,24 +7,25 @@ import (
"github.com/lmika/awstools/internal/common/ui/uimodels" "github.com/lmika/awstools/internal/common/ui/uimodels"
) )
type dispatcherContext struct { type DispatcherContext struct {
d *Dispatcher Publisher MessagePublisher
} }
func (dc dispatcherContext) Messagef(format string, args ...interface{}) { func (dc DispatcherContext) Messagef(format string, args ...interface{}) {
dc.d.publisher.Send(events.Message(fmt.Sprintf(format, args...))) dc.Publisher.Send(events.Message(fmt.Sprintf(format, args...)))
} }
func (dc dispatcherContext) Send(teaMessage tea.Msg) { func (dc DispatcherContext) Send(teaMessage tea.Msg) {
dc.d.publisher.Send(teaMessage) dc.Publisher.Send(teaMessage)
} }
func (dc dispatcherContext) Message(msg string) { func (dc DispatcherContext) Message(msg string) {
dc.d.publisher.Send(events.Message(msg)) dc.Publisher.Send(events.Message(msg))
} }
func (dc dispatcherContext) Input(prompt string, onDone uimodels.Operation) { func (dc DispatcherContext) Input(prompt string, onDone uimodels.Operation) {
dc.d.publisher.Send(events.PromptForInput{ dc.Publisher.Send(events.PromptForInput{
Prompt: prompt,
OnDone: onDone, OnDone: onDone,
}) })
} }

View file

@ -32,7 +32,7 @@ func (d *Dispatcher) Start(ctx context.Context, operation uimodels.Operation) {
d.runningOp = operation d.runningOp = operation
go func() { go func() {
subCtx := uimodels.WithContext(ctx, dispatcherContext{d}) subCtx := uimodels.WithContext(ctx, DispatcherContext{d.publisher})
err := operation.Execute(subCtx) err := operation.Execute(subCtx)
if err != nil { if err != nil {

View file

@ -12,5 +12,6 @@ type Message string
// PromptForInput indicates that the context is requesting a line of input // PromptForInput indicates that the context is requesting a line of input
type PromptForInput struct { type PromptForInput struct {
Prompt string
OnDone uimodels.Operation OnDone uimodels.Operation
} }

View file

@ -5,3 +5,7 @@ import "github.com/lmika/awstools/internal/dynamo-browse/models"
type NewResultSet struct { type NewResultSet struct {
ResultSet *models.ResultSet ResultSet *models.ResultSet
} }
type SetReadWrite struct {
NewValue bool
}

View file

@ -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)
}

View file

@ -4,7 +4,6 @@ import (
"context" "context"
"github.com/lmika/awstools/internal/common/ui/uimodels" "github.com/lmika/awstools/internal/common/ui/uimodels"
"github.com/lmika/awstools/internal/dynamo-browse/services/tables" "github.com/lmika/awstools/internal/dynamo-browse/services/tables"
"github.com/lmika/awstools/internal/dynamo-browse/models"
"github.com/pkg/errors" "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 { return uimodels.OperationFn(func(ctx context.Context) error {
uiCtx := uimodels.Ctx(ctx) 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) uiCtx := uimodels.Ctx(ctx)
if uimodels.PromptValue(ctx) != "y" { if uimodels.PromptValue(ctx) != "y" {
@ -36,7 +50,7 @@ func (c *TableWriteController) Delete(item models.Item) uimodels.Operation {
} }
// Delete the item // 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 return err
} }

View file

@ -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",
},
}

View file

@ -3,6 +3,7 @@ package models
import "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" import "github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
type ResultSet struct { type ResultSet struct {
Table string
Columns []string Columns []string
Items []Item Items []Item
} }

View file

@ -54,6 +54,7 @@ func (s *Service) Scan(ctx context.Context, table string) (*models.ResultSet, er
models.Sort(results, pk, sk) models.Sort(results, pk, sk)
return &models.ResultSet{ return &models.ResultSet{
Table: table,
Columns: columns, Columns: columns,
Items: results, Items: results,
}, nil }, nil

View file

@ -24,6 +24,7 @@ func TestService_Scan(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
// Hash first, then range, then columns in alphabetic order // 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.Columns, []string{"pk", "sk", "alpha", "beta", "gamma"})
assert.Equal(t, rs.Items[0], testdynamo.TestRecordAsItem(t, testData[1])) assert.Equal(t, rs.Items[0], testdynamo.TestRecordAsItem(t, testData[1]))
assert.Equal(t, rs.Items[1], testdynamo.TestRecordAsItem(t, testData[0])) assert.Equal(t, rs.Items[1], testdynamo.TestRecordAsItem(t, testData[0]))

View file

@ -13,16 +13,19 @@ import (
"github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/common/ui/uimodels" "github.com/lmika/awstools/internal/common/ui/uimodels"
"github.com/lmika/awstools/internal/dynamo-browse/controllers" "github.com/lmika/awstools/internal/dynamo-browse/controllers"
"github.com/lmika/awstools/internal/dynamo-browse/models"
"strings" "strings"
"text/tabwriter" "text/tabwriter"
) )
var ( var (
headerStyle = lipgloss.NewStyle(). activeHeaderStyle = lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(lipgloss.Color("#ffffff")). Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#4479ff")) Background(lipgloss.Color("#4479ff"))
inactiveHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1"))
) )
type uiModel struct { type uiModel struct {
@ -32,7 +35,8 @@ type uiModel struct {
tableWidth, tableHeight int tableWidth, tableHeight int
ready bool ready bool
resultSet *models.ResultSet //resultSet *models.ResultSet
state controllers.State
message string message string
pendingInput *events.PromptForInput pendingInput *events.PromptForInput
@ -72,10 +76,11 @@ func (m *uiModel) updateTable() {
return return
} }
newTbl := table.New(m.resultSet.Columns, m.tableWidth, m.tableHeight) resultSet := m.state.ResultSet
newRows := make([]table.Row, len(m.resultSet.Items)) newTbl := table.New(resultSet.Columns, m.tableWidth, m.tableHeight)
for i, r := range m.resultSet.Items { newRows := make([]table.Row, len(resultSet.Items))
newRows[i] = itemTableRow{m.resultSet, r} for i, r := range resultSet.Items {
newRows[i] = itemTableRow{resultSet, r}
} }
newTbl.SetRows(newRows) newTbl.SetRows(newRows)
@ -83,7 +88,8 @@ func (m *uiModel) updateTable() {
} }
func (m *uiModel) selectedItem() (itemTableRow, bool) { 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) selectedItem, ok := m.table.SelectedRow().(itemTableRow)
if ok { if ok {
return selectedItem, true return selectedItem, true
@ -126,9 +132,11 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Local events // Local events
case controllers.NewResultSet: case controllers.NewResultSet:
m.resultSet = msg.ResultSet m.state.ResultSet = msg.ResultSet
m.updateTable() m.updateTable()
m.updateViewportToSelectedMessage() m.updateViewportToSelectedMessage()
case controllers.SetReadWrite:
m.state.InReadWriteMode = msg.NewValue
// Shared events // Shared events
case events.Error: case events.Error:
@ -136,6 +144,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case events.Message: case events.Message:
m.message = string(msg) m.message = string(msg)
case events.PromptForInput: case events.PromptForInput:
m.textInput.Prompt = msg.Prompt
m.textInput.Focus() m.textInput.Focus()
m.textInput.SetValue("") m.textInput.SetValue("")
m.pendingInput = &msg 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 // TODO: these should be moved somewhere else
case "s": case "s":
m.dispatcher.Start(context.Background(), m.tableReadController.Scan()) m.invokeOperation(m.tableReadController.Scan())
case "D": case "D":
if selectedItem, ok := m.selectedItem(); ok { m.invokeOperation(m.tableWriteController.Delete())
m.dispatcher.Start(context.Background(), m.tableWriteController.Delete(selectedItem.item)) case "w":
} m.invokeOperation(m.tableWriteController.EnableReadWrite())
} }
default: default:
m.textInput, textInputCommands = m.textInput.Update(msg) 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) 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 { func (m uiModel) View() string {
if !m.ready { if !m.ready {
return "Initializing" return "Initializing"
@ -229,14 +248,21 @@ func (m uiModel) View() string {
} }
func (m uiModel) headerView() string { func (m uiModel) headerView() string {
title := headerStyle.Render("Table: XXX") var titleText string
line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) 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) return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
} }
func (m uiModel) splitterView() string { func (m uiModel) splitterView() string {
title := headerStyle.Render("Item") title := inactiveHeaderStyle.Render("Item")
line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) line := inactiveHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))))
return lipgloss.JoinHorizontal(lipgloss.Left, title, line) return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
} }

View file

@ -17,10 +17,14 @@ import (
) )
var ( var (
headerStyle = lipgloss.NewStyle(). activeHeaderStyle = lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(lipgloss.Color("#ffffff")). Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#eac610")) Background(lipgloss.Color("#eac610"))
inactiveHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1"))
) )
type uiModel struct { type uiModel struct {
@ -194,14 +198,14 @@ func (m uiModel) View() string {
} }
func (m uiModel) headerView() string { func (m uiModel) headerView() string {
title := headerStyle.Render("Queue: XXX") title := activeHeaderStyle.Render("Queue: XXX")
line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) line := activeHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))))
return lipgloss.JoinHorizontal(lipgloss.Left, title, line) return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
} }
func (m uiModel) splitterView() string { func (m uiModel) splitterView() string {
title := headerStyle.Render("Message") title := activeHeaderStyle.Render("Message")
line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) line := activeHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))))
return lipgloss.JoinHorizontal(lipgloss.Left, title, line) return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
} }

View file

@ -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)
}