diff --git a/server/src/api/votings.go b/server/src/api/votings.go index aac775037b..b765dd37f8 100644 --- a/server/src/api/votings.go +++ b/server/src/api/votings.go @@ -87,6 +87,16 @@ func (s *Server) updateVoting(w http.ResponseWriter, r *http.Request) { board := ctx.Value(identifiers.BoardIdentifier).(uuid.UUID) id := ctx.Value(identifiers.VotingIdentifier).(uuid.UUID) + var body votings.VotingCloseRequest + if err := render.Decode(r, &body); err != nil { + span.SetStatus(codes.Error, "unable to decode body") + span.RecordError(err) + common.Throw(w, r, common.BadRequestError(err)) + return + } + body.ID = id + body.Board = board + notes, err := s.notes.GetAll(ctx, board) if err != nil { span.SetStatus(codes.Error, "failed to get notes") @@ -110,7 +120,8 @@ func (s *Server) updateVoting(w http.ResponseWriter, r *http.Request) { }) } - voting, err := s.votings.Close(ctx, id, board, affectedNotes) + voting, err := s.votings.Update(ctx, id, board, affectedNotes, body.Status) + if err != nil { span.SetStatus(codes.Error, "failed to update voting") span.RecordError(err) diff --git a/server/src/api/votings_test.go b/server/src/api/votings_test.go index ef0edd9214..0df422681b 100644 --- a/server/src/api/votings_test.go +++ b/server/src/api/votings_test.go @@ -94,7 +94,8 @@ func (suite *VotingTestSuite) TestCloseVoting() { s.votings = votingMock s.notes = notesMock - req := technical_helper.NewTestRequestBuilder("PUT", "/", nil) + votingStatus := votings.Closed + req := technical_helper.NewTestRequestBuilder("PUT", "/", strings.NewReader(fmt.Sprintf(`{"status": "%s"}`, votingStatus))) req.Req = logger.InitTestLoggerRequest(req.Request()) req.AddToContext(identifiers.BoardIdentifier, boardId). AddToContext(identifiers.VotingIdentifier, votingId) @@ -102,18 +103,55 @@ func (suite *VotingTestSuite) TestCloseVoting() { notesMock.EXPECT().GetAll(mock.Anything, boardId).Return([]*notes.Note{}, nil) - votingMock.EXPECT().Close(mock.Anything, votingId, boardId, []votings.Note(nil)). + votingMock.EXPECT().Update(mock.Anything, votingId, boardId, []votings.Note(nil), votings.Closed). Return(&votings.Voting{Status: votings.Closed}, tt.err) s.updateVoting(rr, req.Request()) suite.Equal(tt.expectedCode, rr.Result().StatusCode) votingMock.AssertExpectations(suite.T()) - votingMock.AssertNumberOfCalls(suite.T(), "Close", 1) + votingMock.AssertNumberOfCalls(suite.T(), "Update", 1) }) } } +func (suite *VotingTestSuite) TestAbortVoting() { + + testParameterBundles := *TestParameterBundles{}. + Append("all ok", http.StatusOK, nil, false, false, nil). + Append("unexpected error", http.StatusInternalServerError, errors.New("oops"), false, false, nil) + + for _, tt := range testParameterBundles { + suite.Run(tt.name, func() { + s := new(Server) + votingMock := votings.NewMockVotingService(suite.T()) + notesMock := notes.NewMockNotesService(suite.T()) + + boardId, _ := uuid.NewRandom() + votingId, _ := uuid.NewRandom() + s.votings = votingMock + s.notes = notesMock + + votingStatus := votings.Aborted + req := technical_helper.NewTestRequestBuilder("PUT", "/", strings.NewReader(fmt.Sprintf(`{"status": "%s"}`, votingStatus))) + req.Req = logger.InitTestLoggerRequest(req.Request()) + req.AddToContext(identifiers.BoardIdentifier, boardId). + AddToContext(identifiers.VotingIdentifier, votingId) + + notesMock.EXPECT().GetAll(mock.Anything, boardId).Return([]*notes.Note{}, nil) + + votingMock.EXPECT().Update(mock.Anything, votingId, boardId, []votings.Note(nil), votings.Aborted). + Return(&votings.Voting{Status: votings.Aborted}, tt.err) + + rr := httptest.NewRecorder() + s.updateVoting(rr, req.Request()) + suite.Equal(tt.expectedCode, rr.Result().StatusCode) + votingMock.AssertExpectations(suite.T()) + votingMock.AssertNumberOfCalls(suite.T(), "Update", 1) + }) + } +} + func (suite *VotingTestSuite) TestGetVoting() { s := new(Server) votingMock := votings.NewMockVotingService(suite.T()) diff --git a/server/src/votings/api.go b/server/src/votings/api.go index 475427ac97..2bf45c4e3b 100644 --- a/server/src/votings/api.go +++ b/server/src/votings/api.go @@ -14,7 +14,7 @@ type VotingService interface { GetVotes(ctx context.Context, board uuid.UUID, f VoteFilter) ([]*Vote, error) AddVote(ctx context.Context, req VoteRequest) (*Vote, error) RemoveVote(ctx context.Context, req VoteRequest) error - Close(ctx context.Context, id uuid.UUID, board uuid.UUID, affectedNotes []Note) (*Voting, error) + Update(ctx context.Context, id uuid.UUID, board uuid.UUID, affectedNotes []Note, votingStatus VotingStatus) (*Voting, error) } type VotingApi struct { diff --git a/server/src/votings/database.go b/server/src/votings/database.go index 5fb5b9aa4b..cb96cac790 100644 --- a/server/src/votings/database.go +++ b/server/src/votings/database.go @@ -29,27 +29,40 @@ func (d *DB) Create(ctx context.Context, insert DatabaseVotingInsert) (DatabaseV return voting, err } -func (d *DB) Close(ctx context.Context, update DatabaseVotingUpdate) (DatabaseVoting, error) { +func (d *DB) Update(ctx context.Context, update DatabaseVotingUpdate) (DatabaseVoting, error) { var voting DatabaseVoting - updateQuery := d.db.NewUpdate(). + + if update.Status == Closed { + updateQuery := d.db.NewUpdate(). + Model(&update). + Where("id = ?", update.ID). + Where("board = ?", update.Board). + Where("status = ?", Open). + Returning("*") + + updateBoard := d.db.NewUpdate(). + Model((*common.DatabaseBoard)(nil)). + Set("show_voting = (SELECT id FROM \"updateQuery\")"). + Where("id = ?", update.Board) + + err := d.db.NewSelect(). + With("updateQuery", updateQuery). + With("updateBoard", updateBoard). + With("rankUpdate", common.GetRankUpdateQueryForClosedVoting(d.db, "updateQuery")). + Model((*DatabaseVoting)(nil)). + ModelTableExpr("\"updateQuery\" AS voting"). + Scan(common.ContextWithValues(ctx, "Database", d, "Result", &voting), &voting) + + return voting, err + } + + _, err := d.db.NewUpdate(). Model(&update). Where("id = ?", update.ID). Where("board = ?", update.Board). Where("status = ?", Open). - Returning("*") - - updateBoard := d.db.NewUpdate(). - Model((*common.DatabaseBoard)(nil)). - Set("show_voting = (SELECT id FROM \"updateQuery\")"). - Where("id = ?", update.Board) - - err := d.db.NewSelect(). - With("updateQuery", updateQuery). - With("updateBoard", updateBoard). - With("rankUpdate", common.GetRankUpdateQueryForClosedVoting(d.db, "updateQuery")). - Model((*DatabaseVoting)(nil)). - ModelTableExpr("\"updateQuery\" AS voting"). - Scan(common.ContextWithValues(ctx, "Database", d, "Result", &voting), &voting) + Returning("*"). + Exec(common.ContextWithValues(ctx, "Database", d, "Result", &voting), &voting) return voting, err } diff --git a/server/src/votings/database_test.go b/server/src/votings/database_test.go index b2b5d0a379..d49c2711e3 100644 --- a/server/src/votings/database_test.go +++ b/server/src/votings/database_test.go @@ -78,7 +78,7 @@ func (suite *DatabaseVotingTestSuite) Test_Database_Close() { votingId := suite.baseData.Votings["Update"].ID boardId := suite.baseData.Boards["Update"].ID - dbVoting, err := database.Close(context.Background(), + dbVoting, err := database.Update(context.Background(), DatabaseVotingUpdate{ ID: votingId, Board: boardId, @@ -283,6 +283,46 @@ func (suite *DatabaseVotingTestSuite) Test_Database_GetVotes() { assert.Len(t, dbVotes, 18) } +func (suite *DatabaseVotingTestSuite) Test_Database_Cancel() { + t := suite.T() + database := NewVotingDatabase(suite.db) + + votingId := suite.baseData.Votings["Update"].ID + boardId := suite.baseData.Boards["Update"].ID + + dbVoting, err := database.Update(context.Background(), + DatabaseVotingUpdate{ + ID: votingId, + Board: boardId, + Status: Aborted, + }, + ) + + assert.Nil(t, err) + assert.Equal(t, votingId, dbVoting.ID) + assert.Equal(t, boardId, dbVoting.Board) + assert.Equal(t, Aborted, dbVoting.Status) + assert.Equal(t, 7, dbVoting.VoteLimit) + assert.True(t, dbVoting.AllowMultipleVotes) + assert.False(t, dbVoting.ShowVotesOfOthers) + assert.False(t, dbVoting.IsAnonymous) + assert.NotNil(t, dbVoting.CreatedAt) + + // Verify notes are NOT re-ranked after cancel + noteDatabase := notes.NewNotesDatabase(suite.db) + dbNotes, notesErr := noteDatabase.GetAll(context.Background(), boardId) + assert.Nil(t, notesErr) + + noteRankMap := make(map[uuid.UUID]int) + for _, n := range dbNotes { + noteRankMap[n.ID] = n.Rank + } + + assert.Equal(t, 0, noteRankMap[suite.baseData.Notes["Update2"].ID]) + assert.Equal(t, 0, noteRankMap[suite.baseData.Notes["Update1"].ID]) + assert.Equal(t, 0, noteRankMap[suite.baseData.Notes["Update3"].ID]) +} + func (suite *DatabaseVotingTestSuite) seedVotes(db *bun.DB) { log.Println("Seeding voting database test votes") diff --git a/server/src/votings/dto.go b/server/src/votings/dto.go index 99e6483763..ee89bb4835 100644 --- a/server/src/votings/dto.go +++ b/server/src/votings/dto.go @@ -49,8 +49,9 @@ type VotingCreateRequest struct { // VotingCloseRequest represents the request to update a voting session. type VotingCloseRequest struct { - ID uuid.UUID `json:"-"` - Board uuid.UUID `json:"-"` + ID uuid.UUID `json:"-"` + Board uuid.UUID `json:"-"` + Status VotingStatus `json:"status"` } // Voting is the response for all voting requests. diff --git a/server/src/votings/mock_VotingDatabase.go b/server/src/votings/mock_VotingDatabase.go index d3b7281d8a..b03b72d9bc 100644 --- a/server/src/votings/mock_VotingDatabase.go +++ b/server/src/votings/mock_VotingDatabase.go @@ -116,72 +116,6 @@ func (_c *MockVotingDatabase_AddVote_Call) RunAndReturn(run func(ctx context.Con return _c } -// Close provides a mock function for the type MockVotingDatabase -func (_mock *MockVotingDatabase) Close(ctx context.Context, update DatabaseVotingUpdate) (DatabaseVoting, error) { - ret := _mock.Called(ctx, update) - - if len(ret) == 0 { - panic("no return value specified for Close") - } - - var r0 DatabaseVoting - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, DatabaseVotingUpdate) (DatabaseVoting, error)); ok { - return returnFunc(ctx, update) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, DatabaseVotingUpdate) DatabaseVoting); ok { - r0 = returnFunc(ctx, update) - } else { - r0 = ret.Get(0).(DatabaseVoting) - } - if returnFunc, ok := ret.Get(1).(func(context.Context, DatabaseVotingUpdate) error); ok { - r1 = returnFunc(ctx, update) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockVotingDatabase_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' -type MockVotingDatabase_Close_Call struct { - *mock.Call -} - -// Close is a helper method to define mock.On call -// - ctx context.Context -// - update DatabaseVotingUpdate -func (_e *MockVotingDatabase_Expecter) Close(ctx any, update any) *MockVotingDatabase_Close_Call { - return &MockVotingDatabase_Close_Call{Call: _e.mock.On("Close", ctx, update)} -} - -func (_c *MockVotingDatabase_Close_Call) Run(run func(ctx context.Context, update DatabaseVotingUpdate)) *MockVotingDatabase_Close_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 DatabaseVotingUpdate - if args[1] != nil { - arg1 = args[1].(DatabaseVotingUpdate) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockVotingDatabase_Close_Call) Return(databaseVoting DatabaseVoting, err error) *MockVotingDatabase_Close_Call { - _c.Call.Return(databaseVoting, err) - return _c -} - -func (_c *MockVotingDatabase_Close_Call) RunAndReturn(run func(ctx context.Context, update DatabaseVotingUpdate) (DatabaseVoting, error)) *MockVotingDatabase_Close_Call { - _c.Call.Return(run) - return _c -} - // Create provides a mock function for the type MockVotingDatabase func (_mock *MockVotingDatabase) Create(ctx context.Context, insert DatabaseVotingInsert) (DatabaseVoting, error) { ret := _mock.Called(ctx, insert) @@ -596,3 +530,69 @@ func (_c *MockVotingDatabase_RemoveVote_Call) RunAndReturn(run func(ctx context. _c.Call.Return(run) return _c } + +// Update provides a mock function for the type MockVotingDatabase +func (_mock *MockVotingDatabase) Update(ctx context.Context, update DatabaseVotingUpdate) (DatabaseVoting, error) { + ret := _mock.Called(ctx, update) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 DatabaseVoting + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, DatabaseVotingUpdate) (DatabaseVoting, error)); ok { + return returnFunc(ctx, update) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, DatabaseVotingUpdate) DatabaseVoting); ok { + r0 = returnFunc(ctx, update) + } else { + r0 = ret.Get(0).(DatabaseVoting) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, DatabaseVotingUpdate) error); ok { + r1 = returnFunc(ctx, update) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockVotingDatabase_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type MockVotingDatabase_Update_Call struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - ctx context.Context +// - update DatabaseVotingUpdate +func (_e *MockVotingDatabase_Expecter) Update(ctx any, update any) *MockVotingDatabase_Update_Call { + return &MockVotingDatabase_Update_Call{Call: _e.mock.On("Update", ctx, update)} +} + +func (_c *MockVotingDatabase_Update_Call) Run(run func(ctx context.Context, update DatabaseVotingUpdate)) *MockVotingDatabase_Update_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 DatabaseVotingUpdate + if args[1] != nil { + arg1 = args[1].(DatabaseVotingUpdate) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockVotingDatabase_Update_Call) Return(databaseVoting DatabaseVoting, err error) *MockVotingDatabase_Update_Call { + _c.Call.Return(databaseVoting, err) + return _c +} + +func (_c *MockVotingDatabase_Update_Call) RunAndReturn(run func(ctx context.Context, update DatabaseVotingUpdate) (DatabaseVoting, error)) *MockVotingDatabase_Update_Call { + _c.Call.Return(run) + return _c +} diff --git a/server/src/votings/mock_VotingService.go b/server/src/votings/mock_VotingService.go index ae1402dc58..72c571422c 100644 --- a/server/src/votings/mock_VotingService.go +++ b/server/src/votings/mock_VotingService.go @@ -106,86 +106,6 @@ func (_c *MockVotingService_AddVote_Call) RunAndReturn(run func(ctx context.Cont return _c } -// Close provides a mock function for the type MockVotingService -func (_mock *MockVotingService) Close(ctx context.Context, id uuid.UUID, board uuid.UUID, affectedNotes []Note) (*Voting, error) { - ret := _mock.Called(ctx, id, board, affectedNotes) - - if len(ret) == 0 { - panic("no return value specified for Close") - } - - var r0 *Voting - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID, []Note) (*Voting, error)); ok { - return returnFunc(ctx, id, board, affectedNotes) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID, []Note) *Voting); ok { - r0 = returnFunc(ctx, id, board, affectedNotes) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*Voting) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, uuid.UUID, uuid.UUID, []Note) error); ok { - r1 = returnFunc(ctx, id, board, affectedNotes) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockVotingService_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' -type MockVotingService_Close_Call struct { - *mock.Call -} - -// Close is a helper method to define mock.On call -// - ctx context.Context -// - id uuid.UUID -// - board uuid.UUID -// - affectedNotes []Note -func (_e *MockVotingService_Expecter) Close(ctx any, id any, board any, affectedNotes any) *MockVotingService_Close_Call { - return &MockVotingService_Close_Call{Call: _e.mock.On("Close", ctx, id, board, affectedNotes)} -} - -func (_c *MockVotingService_Close_Call) Run(run func(ctx context.Context, id uuid.UUID, board uuid.UUID, affectedNotes []Note)) *MockVotingService_Close_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 uuid.UUID - if args[1] != nil { - arg1 = args[1].(uuid.UUID) - } - var arg2 uuid.UUID - if args[2] != nil { - arg2 = args[2].(uuid.UUID) - } - var arg3 []Note - if args[3] != nil { - arg3 = args[3].([]Note) - } - run( - arg0, - arg1, - arg2, - arg3, - ) - }) - return _c -} - -func (_c *MockVotingService_Close_Call) Return(voting *Voting, err error) *MockVotingService_Close_Call { - _c.Call.Return(voting, err) - return _c -} - -func (_c *MockVotingService_Close_Call) RunAndReturn(run func(ctx context.Context, id uuid.UUID, board uuid.UUID, affectedNotes []Note) (*Voting, error)) *MockVotingService_Close_Call { - _c.Call.Return(run) - return _c -} - // Create provides a mock function for the type MockVotingService func (_mock *MockVotingService) Create(ctx context.Context, body VotingCreateRequest) (*Voting, error) { ret := _mock.Called(ctx, body) @@ -594,3 +514,89 @@ func (_c *MockVotingService_RemoveVote_Call) RunAndReturn(run func(ctx context.C _c.Call.Return(run) return _c } + +// Update provides a mock function for the type MockVotingService +func (_mock *MockVotingService) Update(ctx context.Context, id uuid.UUID, board uuid.UUID, affectedNotes []Note, votingStatus VotingStatus) (*Voting, error) { + ret := _mock.Called(ctx, id, board, affectedNotes, votingStatus) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 *Voting + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID, []Note, VotingStatus) (*Voting, error)); ok { + return returnFunc(ctx, id, board, affectedNotes, votingStatus) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID, []Note, VotingStatus) *Voting); ok { + r0 = returnFunc(ctx, id, board, affectedNotes, votingStatus) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Voting) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, uuid.UUID, uuid.UUID, []Note, VotingStatus) error); ok { + r1 = returnFunc(ctx, id, board, affectedNotes, votingStatus) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockVotingService_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type MockVotingService_Update_Call struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - ctx context.Context +// - id uuid.UUID +// - board uuid.UUID +// - affectedNotes []Note +// - votingStatus VotingStatus +func (_e *MockVotingService_Expecter) Update(ctx any, id any, board any, affectedNotes any, votingStatus any) *MockVotingService_Update_Call { + return &MockVotingService_Update_Call{Call: _e.mock.On("Update", ctx, id, board, affectedNotes, votingStatus)} +} + +func (_c *MockVotingService_Update_Call) Run(run func(ctx context.Context, id uuid.UUID, board uuid.UUID, affectedNotes []Note, votingStatus VotingStatus)) *MockVotingService_Update_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 uuid.UUID + if args[1] != nil { + arg1 = args[1].(uuid.UUID) + } + var arg2 uuid.UUID + if args[2] != nil { + arg2 = args[2].(uuid.UUID) + } + var arg3 []Note + if args[3] != nil { + arg3 = args[3].([]Note) + } + var arg4 VotingStatus + if args[4] != nil { + arg4 = args[4].(VotingStatus) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *MockVotingService_Update_Call) Return(voting *Voting, err error) *MockVotingService_Update_Call { + _c.Call.Return(voting, err) + return _c +} + +func (_c *MockVotingService_Update_Call) RunAndReturn(run func(ctx context.Context, id uuid.UUID, board uuid.UUID, affectedNotes []Note, votingStatus VotingStatus) (*Voting, error)) *MockVotingService_Update_Call { + _c.Call.Return(run) + return _c +} diff --git a/server/src/votings/service.go b/server/src/votings/service.go index 00c4ace323..ef16e7203f 100644 --- a/server/src/votings/service.go +++ b/server/src/votings/service.go @@ -20,7 +20,7 @@ var meter metric.Meter = otel.Meter("scrumlr.io/server/votings") type VotingDatabase interface { Create(ctx context.Context, insert DatabaseVotingInsert) (DatabaseVoting, error) - Close(ctx context.Context, update DatabaseVotingUpdate) (DatabaseVoting, error) + Update(ctx context.Context, update DatabaseVotingUpdate) (DatabaseVoting, error) Get(ctx context.Context, board, id uuid.UUID) (DatabaseVoting, error) GetAll(ctx context.Context, board uuid.UUID) ([]DatabaseVoting, error) GetVotes(ctx context.Context, board uuid.UUID, f VoteFilter) ([]DatabaseVote, error) @@ -263,20 +263,42 @@ func (service *Service) RemoveVote(ctx context.Context, body VoteRequest) error return nil } -func (service *Service) Close(ctx context.Context, id uuid.UUID, board uuid.UUID, affectedNotes []Note) (*Voting, error) { +func (service *Service) createdVoting(ctx context.Context, board uuid.UUID, voting DatabaseVoting) { + ctx, span := tracer.Start(ctx, "scrumlr.votings.service.create") + defer span.End() log := logger.FromContext(ctx) - ctx, span := tracer.Start(ctx, "scrumlr.votings.service.close") + + span.SetAttributes( + attribute.String("scrumlr.votings.service.create.board", board.String()), + attribute.String("scrumlr.votings.service.create.voting", voting.ID.String()), + ) + + err := service.realtime.BroadcastToBoard(ctx, board, realtime.BoardEvent{ + Type: realtime.BoardEventVotingCreated, + Data: new(Voting).From(voting, nil), + }) + + if err != nil { + span.SetStatus(codes.Error, "failed to send voting created") + span.RecordError(err) + log.Errorw("unable to send voting created", "err", err) + } +} + +func (service *Service) Update(ctx context.Context, id uuid.UUID, board uuid.UUID, affectedNotes []Note, votingStatus VotingStatus) (*Voting, error) { + log := logger.FromContext(ctx) + ctx, span := tracer.Start(ctx, "scrumlr.votings.service.update") defer span.End() span.SetAttributes( - attribute.String("scrumlr.votings.service.close.voting", id.String()), - attribute.String("scrumlr.votings.service.close.board", board.String()), + attribute.String("scrumlr.votings.service.update.voting", id.String()), + attribute.String("scrumlr.votings.service.update.board", board.String()), ) - voting, err := service.database.Close(ctx, DatabaseVotingUpdate{ + voting, err := service.database.Update(ctx, DatabaseVotingUpdate{ ID: id, Board: board, - Status: Closed, + Status: votingStatus, }) if err != nil { @@ -286,44 +308,25 @@ func (service *Service) Close(ctx context.Context, id uuid.UUID, board uuid.UUID return nil, CreateVotingError(NotFound, "no active voting session found", err) } - span.SetStatus(codes.Error, "failed to close voting") + span.SetStatus(codes.Error, "failed to update voting") span.RecordError(err) - log.Errorw("unable to close voting", "err", err) - return nil, CreateVotingError(Internal, "failed to close voting", err) + log.Errorw("unable to update voting", "err", err) + return nil, CreateVotingError(Internal, "failed to update voting", err) } - receivedVotes, err := service.database.GetVotes(ctx, board, VoteFilter{Voting: &id}) - if err != nil { - span.SetStatus(codes.Error, "failed to get votes") - span.RecordError(err) - log.Errorw("unable to get votes", "err", err) - return nil, CreateVotingError(Internal, "failed to get votes", err) + var receivedVotes []DatabaseVote + if votingStatus == Closed { + receivedVotes, err = service.database.GetVotes(ctx, board, VoteFilter{Voting: &id}) + if err != nil { + span.SetStatus(codes.Error, "failed to get votes") + span.RecordError(err) + log.Errorw("unable to get votes", "err", err) + return nil, CreateVotingError(Internal, "failed to get votes", err) + } } service.updatedVoting(ctx, board, voting, receivedVotes, affectedNotes) - return new(Voting).From(voting, receivedVotes), err -} - -func (service *Service) createdVoting(ctx context.Context, board uuid.UUID, voting DatabaseVoting) { - ctx, span := tracer.Start(ctx, "scrumlr.votings.service.create") - defer span.End() - log := logger.FromContext(ctx) - - span.SetAttributes( - attribute.String("scrumlr.votings.service.create.board", board.String()), - attribute.String("scrumlr.votings.service.create.voting", voting.ID.String()), - ) - - err := service.realtime.BroadcastToBoard(ctx, board, realtime.BoardEvent{ - Type: realtime.BoardEventVotingCreated, - Data: new(Voting).From(voting, nil), - }) - - if err != nil { - span.SetStatus(codes.Error, "failed to send voting created") - span.RecordError(err) - log.Errorw("unable to send voting created", "err", err) - } + return new(Voting).From(voting, receivedVotes), nil } func (service *Service) updatedVoting(ctx context.Context, board uuid.UUID, voting DatabaseVoting, votes []DatabaseVote, affectedNotes []Note) { diff --git a/server/src/votings/service_integration_test.go b/server/src/votings/service_integration_test.go index 9c7112b407..fcb18495ab 100644 --- a/server/src/votings/service_integration_test.go +++ b/server/src/votings/service_integration_test.go @@ -211,7 +211,7 @@ func (suite *VotingServiceIntegrationTestSuite) Test_CloseVoting() { {ID: suite.baseData.Notes["Update2"].ID, Author: suite.baseData.Notes["Update2"].AuthorID, Text: suite.baseData.Notes["Update2"].Text, Position: NotePosition{Column: suite.baseData.Notes["Update2"].ColumnID}}, {ID: suite.baseData.Notes["Update3"].ID, Author: suite.baseData.Notes["Update3"].AuthorID, Text: suite.baseData.Notes["Update3"].Text, Position: NotePosition{Column: suite.baseData.Notes["Update3"].ColumnID}}, } - voting, err := suite.votingService.Close(ctx, votingId, boardId, affectedNotes) + voting, err := suite.votingService.Update(ctx, votingId, boardId, affectedNotes, Closed) require.NoError(t, err) assert.Equal(t, votingId, voting.ID) @@ -228,6 +228,36 @@ func (suite *VotingServiceIntegrationTestSuite) Test_CloseVoting() { assert.Equal(t, 6, votingData.Voting.VotingResults.Total) } +func (suite *VotingServiceIntegrationTestSuite) Test_AbortVoting() { + t := suite.T() + ctx := context.Background() + + votingId := suite.baseData.Votings["Update"].ID + boardId := suite.baseData.Boards["Update"].ID + + events, err := suite.broker.GetBoardChannel(ctx, boardId) + require.NoError(t, err, "Failed to subscribe to board channel") + + affectedNotes := []Note{ + {ID: suite.baseData.Notes["Update1"].ID, Author: suite.baseData.Notes["Update1"].AuthorID, Text: suite.baseData.Notes["Update1"].Text, Position: NotePosition{Column: suite.baseData.Notes["Update1"].ColumnID}}, + {ID: suite.baseData.Notes["Update2"].ID, Author: suite.baseData.Notes["Update2"].AuthorID, Text: suite.baseData.Notes["Update2"].Text, Position: NotePosition{Column: suite.baseData.Notes["Update2"].ColumnID}}, + {ID: suite.baseData.Notes["Update3"].ID, Author: suite.baseData.Notes["Update3"].AuthorID, Text: suite.baseData.Notes["Update3"].Text, Position: NotePosition{Column: suite.baseData.Notes["Update3"].ColumnID}}, + } + voting, err := suite.votingService.Update(ctx, votingId, boardId, affectedNotes, Aborted) + + require.NoError(t, err) + assert.Equal(t, votingId, voting.ID) + assert.Equal(t, Aborted, voting.Status) + assert.Nil(t, voting.VotingResults) + + msg := <-events + assert.Equal(t, realtime.BoardEventVotingUpdated, msg.Type) + votingData, err := technical_helper.Unmarshal[UpdateVoting](msg.Data) + require.NoError(t, err) + assert.Equal(t, Aborted, votingData.Voting.Status) + assert.Nil(t, votingData.Voting.VotingResults) +} + func (suite *VotingServiceIntegrationTestSuite) Test_CloseVoting_Sorted_Cards() { t := suite.T() ctx := context.Background() @@ -244,7 +274,7 @@ func (suite *VotingServiceIntegrationTestSuite) Test_CloseVoting_Sorted_Cards() {ID: suite.baseData.Notes["SortedUpdate2"].ID, Author: suite.baseData.Notes["SortedUpdate2"].AuthorID, Text: suite.baseData.Notes["SortedUpdate2"].Text, Position: NotePosition{Column: suite.baseData.Notes["SortedUpdate2"].ColumnID}}, {ID: suite.baseData.Notes["SortedUpdate3"].ID, Author: suite.baseData.Notes["SortedUpdate3"].AuthorID, Text: suite.baseData.Notes["SortedUpdate3"].Text, Position: NotePosition{Column: suite.baseData.Notes["SortedUpdate3"].ColumnID}}, } - voting, err := suite.votingService.Close(ctx, votingId, boardId, affectedNotes) + voting, err := suite.votingService.Update(ctx, votingId, boardId, affectedNotes, Closed) require.NoError(t, err) expectedVoting.Status = string(Closed) diff --git a/server/src/votings/service_test.go b/server/src/votings/service_test.go index abfcb81648..8aae2b2942 100644 --- a/server/src/votings/service_test.go +++ b/server/src/votings/service_test.go @@ -263,7 +263,7 @@ func TestCloseVoting(t *testing.T) { votingID := uuid.New() mockDb := NewMockVotingDatabase(t) - mockDb.EXPECT().Close(mock.Anything, DatabaseVotingUpdate{ID: votingID, Board: boardId, Status: Closed}). + mockDb.EXPECT().Update(mock.Anything, DatabaseVotingUpdate{ID: votingID, Board: boardId, Status: Closed}). Return(DatabaseVoting{ID: votingID, Board: boardId, Status: Closed}, nil) mockDb.EXPECT().GetVotes(mock.Anything, boardId, VoteFilter{Voting: &votingID}). Return([]DatabaseVote{}, nil) @@ -274,19 +274,85 @@ func TestCloseVoting(t *testing.T) { broker.Con = mockBroker service := NewVotingService(mockDb, broker) - voting, err := service.Close(context.Background(), votingID, boardId, nil) + voting, err := service.Update(context.Background(), votingID, boardId, nil, Closed) assert.NoError(t, err) assert.NotNil(t, voting) assert.Equal(t, Closed, voting.Status) } +func TestAbortVoting(t *testing.T) { + boardId := uuid.New() + votingID := uuid.New() + + mockDb := NewMockVotingDatabase(t) + mockDb.EXPECT().Update(mock.Anything, DatabaseVotingUpdate{ID: votingID, Board: boardId, Status: Aborted}). + Return(DatabaseVoting{ID: votingID, Board: boardId, Status: Aborted}, nil) + + mockBroker := realtime.NewMockClient(t) + mockBroker.EXPECT().Publish(mock.Anything, mock.AnythingOfType("string"), mock.Anything).Return(nil) + broker := new(realtime.Broker) + broker.Con = mockBroker + + service := NewVotingService(mockDb, broker) + voting, err := service.Update(context.Background(), votingID, boardId, nil, Aborted) + + assert.NoError(t, err) + assert.NotNil(t, voting) + assert.Equal(t, Aborted, voting.Status) +} + +func TestAbortVoting_NotFound(t *testing.T) { + boardId := uuid.New() + votingID := uuid.New() + + mockDb := NewMockVotingDatabase(t) + mockDb.EXPECT().Update(mock.Anything, DatabaseVotingUpdate{ID: votingID, Board: boardId, Status: Aborted}). + Return(DatabaseVoting{}, sql.ErrNoRows) + + mockBroker := realtime.NewMockClient(t) + broker := new(realtime.Broker) + broker.Con = mockBroker + + service := NewVotingService(mockDb, broker) + voting, err := service.Update(context.Background(), votingID, boardId, nil, Aborted) + + assert.Nil(t, voting) + assert.NotNil(t, err) + + var votingErr VotingError + assert.ErrorAs(t, err, &votingErr) + + assert.Equal(t, NotFound, votingErr.Category) +} + +func TestAbortVoting_Failed(t *testing.T) { + boardId := uuid.New() + votingID := uuid.New() + dbError := errors.New("failed to abort") + + mockDb := NewMockVotingDatabase(t) + mockDb.EXPECT().Update(mock.Anything, DatabaseVotingUpdate{ID: votingID, Board: boardId, Status: Aborted}). + Return(DatabaseVoting{}, dbError) + + mockBroker := realtime.NewMockClient(t) + broker := new(realtime.Broker) + broker.Con = mockBroker + + service := NewVotingService(mockDb, broker) + voting, err := service.Update(context.Background(), votingID, boardId, nil, Aborted) + + assert.Nil(t, voting) + assert.NotNil(t, err) + assert.ErrorIs(t, err, dbError) +} + func TestCloseVoting_NotFound(t *testing.T) { boardId := uuid.New() votingID := uuid.New() mockDb := NewMockVotingDatabase(t) - mockDb.EXPECT().Close(mock.Anything, DatabaseVotingUpdate{ID: votingID, Board: boardId, Status: Closed}). + mockDb.EXPECT().Update(mock.Anything, DatabaseVotingUpdate{ID: votingID, Board: boardId, Status: Closed}). Return(DatabaseVoting{}, sql.ErrNoRows) mockBroker := realtime.NewMockClient(t) @@ -294,7 +360,7 @@ func TestCloseVoting_NotFound(t *testing.T) { broker.Con = mockBroker service := NewVotingService(mockDb, broker) - voting, err := service.Close(context.Background(), votingID, boardId, nil) + voting, err := service.Update(context.Background(), votingID, boardId, nil, Closed) assert.Nil(t, voting) assert.NotNil(t, err) @@ -311,7 +377,7 @@ func TestCloseVoting_Failed(t *testing.T) { dbError := errors.New("failed to close") mockDb := NewMockVotingDatabase(t) - mockDb.EXPECT().Close(mock.Anything, DatabaseVotingUpdate{ID: votingID, Board: boardId, Status: Closed}). + mockDb.EXPECT().Update(mock.Anything, DatabaseVotingUpdate{ID: votingID, Board: boardId, Status: Closed}). Return(DatabaseVoting{}, dbError) mockBroker := realtime.NewMockClient(t) @@ -319,7 +385,7 @@ func TestCloseVoting_Failed(t *testing.T) { broker.Con = mockBroker service := NewVotingService(mockDb, broker) - voting, err := service.Close(context.Background(), votingID, boardId, nil) + voting, err := service.Update(context.Background(), votingID, boardId, nil, Closed) assert.Nil(t, voting) assert.NotNil(t, err) @@ -333,7 +399,7 @@ func TestCloseVoting_FailedToGetVotes(t *testing.T) { dbError := errors.New("failed to get votes") mockDb := NewMockVotingDatabase(t) - mockDb.EXPECT().Close(mock.Anything, DatabaseVotingUpdate{ID: votingID, Board: boardId, Status: status}). + mockDb.EXPECT().Update(mock.Anything, DatabaseVotingUpdate{ID: votingID, Board: boardId, Status: status}). Return(DatabaseVoting{ID: votingID, Board: boardId, Status: status}, nil) mockDb.EXPECT().GetVotes(mock.Anything, boardId, VoteFilter{Voting: &votingID}). Return([]DatabaseVote{}, dbError) @@ -343,7 +409,7 @@ func TestCloseVoting_FailedToGetVotes(t *testing.T) { broker.Con = mockBroker service := NewVotingService(mockDb, broker) - voting, err := service.Close(context.Background(), votingID, boardId, nil) + voting, err := service.Update(context.Background(), votingID, boardId, nil, Closed) assert.Nil(t, voting) assert.NotNil(t, err) diff --git a/server/src/votings/voting_status.go b/server/src/votings/voting_status.go index 633a01fe24..a11df20e96 100644 --- a/server/src/votings/voting_status.go +++ b/server/src/votings/voting_status.go @@ -5,13 +5,16 @@ import ( "errors" ) -// VotingStatus is the state of a voting session and can be one of open, aborted or closed. +// VotingStatus is the state of a voting session and can be one of open, canceled or closed. type VotingStatus string const ( // Open is the state for an open voting session, meaning that votes are allowed. Open VotingStatus = "OPEN" + // Aborted represents an aborted voting session (the DB enum uses ABORTED) + Aborted VotingStatus = "ABORTED" + // Closed is the state for a closed voting session. // // The results of the voting session are available to all participants of a board. @@ -25,7 +28,7 @@ func (votingStatus *VotingStatus) UnmarshalJSON(b []byte) error { } unmarshalledVotingStatus := VotingStatus(s) switch unmarshalledVotingStatus { - case Open, Closed: + case Open, Closed, Aborted: *votingStatus = unmarshalledVotingStatus return nil } diff --git a/server/src/votings/voting_status_test.go b/server/src/votings/voting_status_test.go index ef4e061ea0..f901866edc 100644 --- a/server/src/votings/voting_status_test.go +++ b/server/src/votings/voting_status_test.go @@ -8,7 +8,7 @@ import ( ) func TestVotingStatusEnum(t *testing.T) { - values := []VotingStatus{Open, Closed} + values := []VotingStatus{Open, Closed, Aborted} for _, value := range values { var votingStatus VotingStatus err := votingStatus.UnmarshalJSON(fmt.Appendf(nil, "\"%s\"", value))