Skip to content

Commit

Permalink
feat(comment): introduce appeal comments (#127)
Browse files Browse the repository at this point in the history
* feat(comment): introduce appeal comments

* fix: check appeal existance on list comments

* test: add repository and service tests

* test: add test for comment handler

* chore: update proton

* chore: update ctx with cancel

* chore: update dependancy to appeal service

* chore: use appeal service struct and update tests

* refactor: use centralized interface and mocks

* refactor: put default order by in a constant

* feat: introduce parent type

* chore: update proton commit

* chore: update proton commit

* chore: update proton commit

* chore: update proton commit

---------

Co-authored-by: Muhammad Idil Haq Amir <[email protected]>
  • Loading branch information
rahmatrhd and Muhammad Idil Haq Amir authored May 8, 2024
1 parent 6bef30c commit 5aeae47
Show file tree
Hide file tree
Showing 31 changed files with 3,899 additions and 1,543 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ COMMIT := $(shell git rev-parse --short HEAD)
TAG := "$(shell git rev-list --tags --max-count=1)"
VERSION := "$(shell git describe --tags ${TAG})-next"
BUILD_DIR=dist
PROTON_COMMIT := "8ded420b43b3498b3b0801959df841a8fd82ff0b"
PROTON_COMMIT := "6fdca7f234f339540c675cc6db64f2483a6f4db5"

.PHONY: all build clean test tidy vet proto setup format generate

Expand Down
22 changes: 22 additions & 0 deletions api/handler/v1beta1/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,28 @@ func (a *adapter) ToActivityProto(activity *domain.Activity) (*guardianv1beta1.P
return activityProto, nil
}

func (a *adapter) ToCommentProto(c *domain.Comment) *guardianv1beta1.AppealComment {
if c == nil {
return nil
}

commentProto := &guardianv1beta1.AppealComment{
Id: c.ID,
AppealId: c.ParentID,
CreatedBy: c.CreatedBy,
Body: c.Body,
}

if !c.CreatedAt.IsZero() {
commentProto.CreatedAt = timestamppb.New(c.CreatedAt)
}
if !c.UpdatedAt.IsZero() {
commentProto.UpdatedAt = timestamppb.New(c.UpdatedAt)
}

return commentProto
}

func (a *adapter) fromConditionProto(c *guardianv1beta1.Condition) *domain.Condition {
if c == nil {
return nil
Expand Down
68 changes: 68 additions & 0 deletions api/handler/v1beta1/comment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package v1beta1

import (
"context"
"errors"

guardianv1beta1 "github.com/goto/guardian/api/proto/gotocompany/guardian/v1beta1"
"github.com/goto/guardian/core/appeal"
"github.com/goto/guardian/core/comment"
"github.com/goto/guardian/domain"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

func (s *GRPCServer) ListAppealComments(ctx context.Context, req *guardianv1beta1.ListAppealCommentsRequest) (*guardianv1beta1.ListAppealCommentsResponse, error) {
comments, err := s.appealService.ListComments(ctx, domain.ListCommentsFilter{
ParentID: req.GetAppealId(),
OrderBy: req.GetOrderBy(),
})
if err != nil {
switch {
case errors.Is(err, appeal.ErrAppealNotFound):
return nil, status.Errorf(codes.NotFound, err.Error())
default:
return nil, s.internalError(ctx, "failed to list comments: %s", err)
}
}

commentProtos := []*guardianv1beta1.AppealComment{}
for _, c := range comments {
commentProtos = append(commentProtos, s.adapter.ToCommentProto(c))
}

return &guardianv1beta1.ListAppealCommentsResponse{
Comments: commentProtos,
}, nil
}

func (s *GRPCServer) CreateAppealComment(ctx context.Context, req *guardianv1beta1.CreateAppealCommentRequest) (*guardianv1beta1.CreateAppealCommentResponse, error) {
actor, err := s.getUser(ctx)
if err != nil {
return nil, status.Error(codes.Unauthenticated, err.Error())
}

c := &domain.Comment{
ParentID: req.GetAppealId(),
Body: req.GetBody(),
CreatedBy: actor,
}
if err := s.appealService.CreateComment(ctx, c); err != nil {
switch {
case
errors.Is(err, comment.ErrEmptyCommentParentType),
errors.Is(err, comment.ErrEmptyCommentParentID),
errors.Is(err, comment.ErrEmptyCommentCreator),
errors.Is(err, comment.ErrEmptyCommentBody):
return nil, s.invalidArgument(ctx, err.Error())
case errors.Is(err, appeal.ErrAppealNotFound):
return nil, status.Errorf(codes.NotFound, err.Error())
default:
return nil, s.internalError(ctx, "failed to create comment: %s", err)
}
}

return &guardianv1beta1.CreateAppealCommentResponse{
Comment: s.adapter.ToCommentProto(c),
}, nil
}
249 changes: 249 additions & 0 deletions api/handler/v1beta1/comment_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
package v1beta1_test

import (
"context"
"errors"
"time"

"github.com/google/uuid"
guardianv1beta1 "github.com/goto/guardian/api/proto/gotocompany/guardian/v1beta1"
"github.com/goto/guardian/core/appeal"
"github.com/goto/guardian/core/comment"
"github.com/goto/guardian/domain"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)

func (s *GrpcHandlersSuite) TestListComments() {
s.Run("should return list of comments on success", func() {
s.setup()
timeNow := time.Now()

appealID := uuid.New().String()
dummyComments := []*domain.Comment{
{
ID: uuid.New().String(),
ParentID: appealID,
CreatedBy: "[email protected]",
Body: "comment 1",
CreatedAt: timeNow,
UpdatedAt: timeNow,
},
{
ID: uuid.New().String(),
ParentID: appealID,
CreatedBy: "[email protected]",
Body: "comment 2",
CreatedAt: timeNow,
UpdatedAt: timeNow,
},
}
expectedResponse := &guardianv1beta1.ListAppealCommentsResponse{
Comments: []*guardianv1beta1.AppealComment{
{
Id: dummyComments[0].ID,
AppealId: appealID,
CreatedBy: "[email protected]",
Body: "comment 1",
CreatedAt: timestamppb.New(timeNow),
UpdatedAt: timestamppb.New(timeNow),
},
{
Id: dummyComments[1].ID,
AppealId: appealID,
CreatedBy: "[email protected]",
Body: "comment 2",
CreatedAt: timestamppb.New(timeNow),
UpdatedAt: timestamppb.New(timeNow),
},
},
}
expectedOrderBy := []string{"created_at:desc"}

s.appealService.EXPECT().
ListComments(mock.MatchedBy(func(ctx context.Context) bool { return true }), mock.AnythingOfType("domain.ListCommentsFilter")).
Return(dummyComments, nil).
Run(func(_a0 context.Context, filter domain.ListCommentsFilter) {
s.Equal(appealID, filter.ParentID)
s.Equal(expectedOrderBy, filter.OrderBy)
})
defer s.appealService.AssertExpectations(s.T())

req := &guardianv1beta1.ListAppealCommentsRequest{
AppealId: appealID,
OrderBy: expectedOrderBy,
}
res, err := s.grpcServer.ListAppealComments(context.Background(), req)

s.NoError(err)
s.Equal(expectedResponse, res)
})

s.Run("should return error codes according to the service error", func() {
testCases := []struct {
name string
expecedError error
expectedGRPCCode codes.Code
}{
{
name: "should return not found error when appeal not found",
expecedError: appeal.ErrAppealNotFound,
expectedGRPCCode: codes.NotFound,
},
{
name: "should return internal error when service fails",
expecedError: errors.New("unexpected error"),
expectedGRPCCode: codes.Internal,
},
}

for _, tc := range testCases {
s.Run(tc.name, func() {
s.setup()

appealID := uuid.New().String()
s.appealService.EXPECT().
ListComments(mock.MatchedBy(func(ctx context.Context) bool { return true }), mock.AnythingOfType("domain.ListCommentsFilter")).
Return(nil, tc.expecedError)
defer s.appealService.AssertExpectations(s.T())

req := &guardianv1beta1.ListAppealCommentsRequest{
AppealId: appealID,
}
res, err := s.grpcServer.ListAppealComments(context.Background(), req)

s.Equal(tc.expectedGRPCCode, status.Code(err))
s.Nil(res)
})
}
})
}

func (s *GrpcHandlersSuite) TestCreateComment() {
s.Run("should return comment on success", func() {
s.setup()
timeNow := time.Now()

appealID := uuid.New().String()
actor := "[email protected]"
commentBody := "test comment"
expectedNewComment := &domain.Comment{
ID: uuid.New().String(),
ParentID: appealID,
CreatedBy: actor,
Body: commentBody,
CreatedAt: timeNow,
UpdatedAt: timeNow,
}
expectedResponse := &guardianv1beta1.CreateAppealCommentResponse{
Comment: &guardianv1beta1.AppealComment{
Id: expectedNewComment.ID,
AppealId: appealID,
CreatedBy: actor,
Body: commentBody,
CreatedAt: timestamppb.New(timeNow),
UpdatedAt: timestamppb.New(timeNow),
},
}

s.appealService.EXPECT().
CreateComment(mock.MatchedBy(func(ctx context.Context) bool { return true }), mock.AnythingOfType("*domain.Comment")).
Return(nil).
Run(func(_a0 context.Context, c *domain.Comment) {
s.Equal(appealID, c.ParentID)
s.Equal(actor, c.CreatedBy)
s.Equal(commentBody, c.Body)

// updated values
c.ID = expectedNewComment.ID
c.CreatedAt = expectedNewComment.CreatedAt
c.UpdatedAt = expectedNewComment.UpdatedAt
})
defer s.appealService.AssertExpectations(s.T())

req := &guardianv1beta1.CreateAppealCommentRequest{
AppealId: appealID,
Body: commentBody,
}
ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, actor)
res, err := s.grpcServer.CreateAppealComment(ctx, req)

s.NoError(err)
s.Equal(expectedResponse, res)
})

s.Run("should return unauthenticated error when user is not authenticated", func() {
s.setup()
req := &guardianv1beta1.CreateAppealCommentRequest{
AppealId: uuid.New().String(),
Body: "test comment content",
}
ctx := context.Background() // no authenticated user in context
res, err := s.grpcServer.CreateAppealComment(ctx, req)

s.Equal(codes.Unauthenticated, status.Code(err))
s.Nil(res)
})

s.Run("should return error codes according to the service error", func() {
testCases := []struct {
name string
expecedError error
expectedGRPCCode codes.Code
}{
{
name: "should return invalid argument error when parent type is empty",
expecedError: comment.ErrEmptyCommentParentType,
expectedGRPCCode: codes.InvalidArgument,
},
{
name: "should return invalid argument error when parent id is empty",
expecedError: comment.ErrEmptyCommentParentID,
expectedGRPCCode: codes.InvalidArgument,
},
{
name: "should return invalid argument error when comment creator is empty",
expecedError: comment.ErrEmptyCommentCreator,
expectedGRPCCode: codes.InvalidArgument,
},
{
name: "should return invalid argument error when comment body is empty",
expecedError: comment.ErrEmptyCommentBody,
expectedGRPCCode: codes.InvalidArgument,
},
{
name: "should return not found error when appeal not found",
expecedError: appeal.ErrAppealNotFound,
expectedGRPCCode: codes.NotFound,
},
{
name: "should return internal error when service fails",
expecedError: errors.New("unexpected error"),
expectedGRPCCode: codes.Internal,
},
}

for _, tc := range testCases {
s.Run(tc.name, func() {
s.setup()

s.appealService.EXPECT().
CreateComment(mock.MatchedBy(func(ctx context.Context) bool { return true }), mock.AnythingOfType("*domain.Comment")).
Return(tc.expecedError)
defer s.appealService.AssertExpectations(s.T())

req := &guardianv1beta1.CreateAppealCommentRequest{
AppealId: uuid.New().String(),
Body: "test comment content",
}
ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, "[email protected]")
res, err := s.grpcServer.CreateAppealComment(ctx, req)

s.Equal(tc.expectedGRPCCode, status.Code(err))
s.Nil(res)
})
}
})
}
4 changes: 4 additions & 0 deletions api/handler/v1beta1/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ type ProtoAdapter interface {
FromGrantProto(*guardianv1beta1.Grant) *domain.Grant

ToActivityProto(*domain.Activity) (*guardianv1beta1.ProviderActivity, error)

ToCommentProto(*domain.Comment) *guardianv1beta1.AppealComment
}

//go:generate mockery --name=resourceService --exported --with-expecter
Expand Down Expand Up @@ -95,6 +97,8 @@ type appealService interface {
AddApprover(ctx context.Context, appealID, approvalID, email string) (*domain.Appeal, error)
DeleteApprover(ctx context.Context, appealID, approvalID, email string) (*domain.Appeal, error)
UpdateApproval(ctx context.Context, approvalAction domain.ApprovalAction) (*domain.Appeal, error)
ListComments(context.Context, domain.ListCommentsFilter) ([]*domain.Comment, error)
CreateComment(context.Context, *domain.Comment) error
}

//go:generate mockery --name=approvalService --exported --with-expecter
Expand Down
Loading

0 comments on commit 5aeae47

Please sign in to comment.