diff --git a/client/client.go b/client/client.go index 85e76a9dc..1329f5176 100644 --- a/client/client.go +++ b/client/client.go @@ -42,6 +42,7 @@ type Client interface { GetPost(postID string, etag string) (*model.Post, *model.Response, error) CreatePost(post *model.Post) (*model.Post, *model.Response, error) GetPostsForChannel(channelID string, page, perPage int, etag string, collapsedThreads bool) (*model.PostList, *model.Response, error) + GetPostsSince(channelID string, since int64, collapsedThreads bool) (*model.PostList, *model.Response, error) DoAPIPost(url string, data string) (*http.Response, error) GetLdapGroups() ([]*model.Group, *model.Response, error) GetGroupsByChannel(channelID string, groupOpts model.GroupSearchOpts) ([]*model.GroupWithSchemeAdmin, int, *model.Response, error) diff --git a/commands/post.go b/commands/post.go index 9d610afa2..0966de571 100644 --- a/commands/post.go +++ b/commands/post.go @@ -6,6 +6,7 @@ package commands import ( "encoding/json" "fmt" + "time" "github.com/mattermost/mattermost-server/v6/model" @@ -38,6 +39,11 @@ var PostListCmd = &cobra.Command{ RunE: withClient(postListCmdF), } +const ( + ISO8601Layout = "2006-01-02T15:04:05-07:00" + PostTimeFormat = "2006-01-02 15:04:05-07:00" +) + func init() { PostCreateCmd.Flags().StringP("message", "m", "", "Message for the post") PostCreateCmd.Flags().StringP("reply-to", "r", "", "Post id to reply to") @@ -45,6 +51,7 @@ func init() { PostListCmd.Flags().IntP("number", "n", 20, "Number of messages to list") PostListCmd.Flags().BoolP("show-ids", "i", false, "Show posts ids") PostListCmd.Flags().BoolP("follow", "f", false, "Output appended data as new messages are posted to the channel") + PostListCmd.Flags().StringP("since", "s", "", "List messages posted after a certain time (ISO 8601)") PostCmd.AddCommand( PostCreateCmd, @@ -110,7 +117,7 @@ func eventDataToPost(eventData map[string]interface{}) (*model.Post, error) { return post, nil } -func printPost(c client.Client, post *model.Post, usernames map[string]string, showIds bool) { +func printPost(c client.Client, post *model.Post, usernames map[string]string, showIds, showTimestamp bool) { var username string if usernames[post.UserId] != "" { @@ -125,11 +132,32 @@ func printPost(c client.Client, post *model.Post, usernames map[string]string, s } } - if showIds { - printer.PrintT(fmt.Sprintf("\u001b[31m%s\u001b[0m \u001b[34;1m[%s]\u001b[0m {{.Message}}", post.Id, username), post) + postTime := model.GetTimeForMillis(post.CreateAt) + createdAt := postTime.Format(PostTimeFormat) + + if showTimestamp { + printer.PrintT(fmt.Sprintf("\u001b[32m%s\u001b[0m \u001b[34;1m[%s]\u001b[0m {{.Message}}", createdAt, username), post) } else { - printer.PrintT(fmt.Sprintf("\u001b[34;1m[%s]\u001b[0m {{.Message}}", username), post) + if showIds { + printer.PrintT(fmt.Sprintf("\u001b[31m%s\u001b[0m \u001b[34;1m[%s]\u001b[0m {{.Message}}", post.Id, username), post) + } else { + printer.PrintT(fmt.Sprintf("\u001b[34;1m[%s]\u001b[0m {{.Message}}", username), post) + } + } +} + +func getPostList(client client.Client, channelID, since string, perPage int) (*model.PostList, *model.Response, error) { + if since == "" { + return client.GetPostsForChannel(channelID, 0, perPage, "", false) + } + + sinceTime, err := time.Parse(ISO8601Layout, since) + if err != nil { + return nil, nil, fmt.Errorf("invalid since time '%s'", since) } + + sinceTimeMillis := model.GetMillisForTime(sinceTime) + return client.GetPostsSince(channelID, sinceTimeMillis, false) } func postListCmdF(c client.Client, cmd *cobra.Command, args []string) error { @@ -143,17 +171,19 @@ func postListCmdF(c client.Client, cmd *cobra.Command, args []string) error { number, _ := cmd.Flags().GetInt("number") showIds, _ := cmd.Flags().GetBool("show-ids") follow, _ := cmd.Flags().GetBool("follow") + since, _ := cmd.Flags().GetString("since") - postList, _, err := c.GetPostsForChannel(channel.Id, 0, number, "", false) + postList, _, err := getPostList(c, channel.Id, since, number) if err != nil { return err } posts := postList.ToSlice() + showTimestamp := len(since) > 0 usernames := map[string]string{} for i := 1; i <= len(posts); i++ { post := posts[len(posts)-i] - printPost(c, post, usernames, showIds) + printPost(c, post, usernames, showIds, showTimestamp) } if follow { @@ -176,7 +206,7 @@ func postListCmdF(c client.Client, cmd *cobra.Command, args []string) error { fmt.Println("Error parsing incoming post: " + err.Error()) } if post.ChannelId == channel.Id { - printPost(c, post, usernames, showIds) + printPost(c, post, usernames, showIds, showTimestamp) } } } diff --git a/commands/post_e2e_test.go b/commands/post_e2e_test.go new file mode 100644 index 000000000..a29cfc0ee --- /dev/null +++ b/commands/post_e2e_test.go @@ -0,0 +1,194 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "github.com/spf13/cobra" + + "github.com/mattermost/mmctl/v6/client" + "github.com/mattermost/mmctl/v6/printer" + + "github.com/mattermost/mattermost-server/v6/model" +) + +func (s *MmctlE2ETestSuite) TestPostListCmd() { + s.SetupTestHelper().InitBasic() + + var createNewChannelAndPosts = func() (string, *model.Post, *model.Post) { + channelName := model.NewRandomString(10) + channelDisplayName := "channelDisplayName" + + channel, err := s.th.App.CreateChannel(s.th.Context, &model.Channel{Name: channelName, DisplayName: channelDisplayName, Type: model.ChannelTypeOpen, TeamId: s.th.BasicTeam.Id}, false) + s.Require().Nil(err) + + post1, err := s.th.App.CreatePost(s.th.Context, &model.Post{Message: model.NewRandomString(15), UserId: s.th.BasicUser.Id, ChannelId: channel.Id}, channel, false, false) + s.Require().Nil(err) + + post2, err := s.th.App.CreatePost(s.th.Context, &model.Post{Message: model.NewRandomString(15), UserId: s.th.BasicUser.Id, ChannelId: channel.Id}, channel, false, false) + s.Require().Nil(err) + + return channelName, post1, post2 + } + + s.RunForSystemAdminAndLocal("List all posts for a channel", func(c client.Client) { + printer.Clean() + + teamName := s.th.BasicTeam.Name + channelName, post1, post2 := createNewChannelAndPosts() + + cmd := &cobra.Command{} + cmd.Flags().Int("number", 2, "") + + err := postListCmdF(c, cmd, []string{teamName + ":" + channelName}) + s.Require().Nil(err) + s.Equal(2, len(printer.GetLines())) + + printedPost1, ok := printer.GetLines()[0].(*model.Post) + s.Require().True(ok) + s.Require().Equal(printedPost1.Message, post1.Message) + + printedPost2, ok := printer.GetLines()[1].(*model.Post) + s.Require().True(ok) + s.Require().Equal(printedPost2.Message, post2.Message) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("List all posts for a channel without permissions", func() { + printer.Clean() + + teamName := s.th.BasicTeam.Name + channelName, _, _ := createNewChannelAndPosts() + + cmd := &cobra.Command{} + cmd.Flags().Int("number", 2, "") + + err := postListCmdF(s.th.Client, cmd, []string{teamName + ":" + channelName}) + s.Require().NotNil(err) + s.Require().Contains(err.Error(), "You do not have the appropriate permissions.") + }) + + s.RunForSystemAdminAndLocal("List all posts for a channel with since flag", func(c client.Client) { + printer.Clean() + + ISO8601ValidString := "2006-01-02T15:04:05-07:00" + teamName := s.th.BasicTeam.Name + channelName, post1, post2 := createNewChannelAndPosts() + + cmd := &cobra.Command{} + cmd.Flags().String("since", ISO8601ValidString, "") + + err := postListCmdF(c, cmd, []string{teamName + ":" + channelName}) + s.Require().Nil(err) + s.Equal(2, len(printer.GetLines())) + + printedPost1, ok := printer.GetLines()[0].(*model.Post) + s.Require().True(ok) + s.Require().Equal(printedPost1.Message, post1.Message) + + printedPost2, ok := printer.GetLines()[1].(*model.Post) + s.Require().True(ok) + s.Require().Equal(printedPost2.Message, post2.Message) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("List all posts for a channel with since flag without permissions", func() { + printer.Clean() + + ISO8601ValidString := "2006-01-02T15:04:05-07:00" + teamName := s.th.BasicTeam.Name + channelName, _, _ := createNewChannelAndPosts() + + cmd := &cobra.Command{} + cmd.Flags().String("since", ISO8601ValidString, "") + + err := postListCmdF(s.th.Client, cmd, []string{teamName + ":" + channelName}) + s.Require().NotNil(err) + s.Require().Contains(err.Error(), "You do not have the appropriate permissions.") + }) +} + +func (s *MmctlE2ETestSuite) TestPostCreateCmd() { + s.SetupTestHelper().InitBasic() + + s.Run("Create a post for System Admin Client", func() { + printer.Clean() + + msgArg := "some text" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + + err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name}) + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Create a post for Client", func() { + printer.Clean() + + msgArg := "some text" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + + err := postCreateCmdF(s.th.Client, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name}) + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Create a post for Local Client should fail", func() { + printer.Clean() + + msgArg := "some text" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + + err := postCreateCmdF(s.th.LocalClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name}) + s.Require().NotNil(err) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Reply to a an existing post for System Admin Client", func() { + printer.Clean() + + msgArg := "some text" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + cmd.Flags().String("reply-to", s.th.BasicPost.Id, "") + + err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name}) + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Reply to a an existing post for Client", func() { + printer.Clean() + + msgArg := "some text" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + cmd.Flags().String("reply-to", s.th.BasicPost.Id, "") + + err := postCreateCmdF(s.th.Client, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name}) + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Reply to a an existing post for Local Client should fail", func() { + printer.Clean() + + msgArg := "some text" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + cmd.Flags().String("reply-to", s.th.BasicPost.Id, "") + + err := postCreateCmdF(s.th.LocalClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name}) + s.Require().NotNil(err) + s.Len(printer.GetErrorLines(), 0) + }) +} diff --git a/commands/post_test.go b/commands/post_test.go index 97d875e22..5e0eb81fc 100644 --- a/commands/post_test.go +++ b/commands/post_test.go @@ -4,6 +4,8 @@ package commands import ( + "time" + "github.com/mattermost/mattermost-server/v6/model" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -140,3 +142,117 @@ func (s *MmctlUnitTestSuite) TestPostCreateCmdF() { s.Len(printer.GetErrorLines(), 0) }) } + +func (s *MmctlUnitTestSuite) TestPostListCmdF() { + s.Run("no channel specified", func() { + sinceArg := "invalid-date" + + cmd := &cobra.Command{} + cmd.Flags().String("since", sinceArg, "") + + err := postListCmdF(s.client, cmd, []string{"", sinceArg}) + s.Require().EqualError(err, "Unable to find channel ''") + }) + + s.Run("invalid time for since flag", func() { + sinceArg := "invalid-date" + mockChannel := model.Channel{Name: channelName} + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().String("since", sinceArg, "") + + err := postListCmdF(s.client, cmd, []string{channelName, sinceArg}) + s.Require().Contains(err.Error(), "invalid since time 'invalid-date'") + }) + + s.Run("list posts for a channel", func() { + printer.Clean() + mockChannel := model.Channel{Name: channelName, Id: channelID} + mockPost := &model.Post{Message: "some text", Id: "some-id", UserId: userID, CreateAt: model.GetMillisForTime(time.Now())} + mockPostList := model.NewPostList() + mockPostList.AddPost(mockPost) + mockPostList.AddOrder(mockPost.Id) + mockUser := model.User{Id: userID, Username: "some-user"} + + cmd := &cobra.Command{} + cmd.Flags().Int("number", 1, "") + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPostsForChannel(channelID, 0, 1, "", false). + Return(mockPostList, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(userID, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + printer.Clean() + err := postListCmdF(s.client, cmd, []string{channelName}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], mockPost) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("list posts for a channel from a certain time (valid date)", func() { + printer.Clean() + + ISO8601ValidString := "2006-01-02T15:04:05-07:00" + + sinceArg := "2006-01-02T15:04:05-07:00" + sinceTime, err := time.Parse(ISO8601ValidString, sinceArg) + s.Require().Nil(err) + + sinceTimeMillis := model.GetMillisForTime(sinceTime) + + mockChannel := model.Channel{Name: channelName, Id: channelID} + mockPost := &model.Post{Message: "some text", Id: "some-id", UserId: userID} + mockPostList := model.NewPostList() + mockPostList.AddPost(mockPost) + mockPostList.AddOrder(mockPost.Id) + mockUser := model.User{Id: userID, Username: "some-user"} + + cmd := &cobra.Command{} + cmd.Flags().Int("number", 1, "") + cmd.Flags().String("since", sinceArg, "") + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPostsSince(channelID, sinceTimeMillis, false). + Return(mockPostList, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(userID, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + err = postListCmdF(s.client, cmd, []string{channelName}) + s.Require().Nil(err) + s.Require().Equal(printer.GetLines()[0], mockPost) + s.Len(printer.GetLines(), 1) + s.Len(printer.GetErrorLines(), 0) + }) +} diff --git a/docs/mmctl_post_list.rst b/docs/mmctl_post_list.rst index 08a2f4632..644d4ff75 100644 --- a/docs/mmctl_post_list.rst +++ b/docs/mmctl_post_list.rst @@ -28,10 +28,11 @@ Options :: - -f, --follow Output appended data as new messages are posted to the channel - -h, --help help for list - -n, --number int Number of messages to list (default 20) - -i, --show-ids Show posts ids + -f, --follow Output appended data as new messages are posted to the channel + -h, --help help for list + -n, --number int Number of messages to list (default 20) + -i, --show-ids Show posts ids + -s, --since string List messages posted after a certain time (ISO 8601) Options inherited from parent commands ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/mocks/client_mock.go b/mocks/client_mock.go index 05047db23..057629cdc 100644 --- a/mocks/client_mock.go +++ b/mocks/client_mock.go @@ -1037,6 +1037,22 @@ func (mr *MockClientMockRecorder) GetPostsForChannel(arg0, arg1, arg2, arg3, arg return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostsForChannel", reflect.TypeOf((*MockClient)(nil).GetPostsForChannel), arg0, arg1, arg2, arg3, arg4) } +// GetPostsSince mocks base method +func (m *MockClient) GetPostsSince(arg0 string, arg1 int64, arg2 bool) (*model.PostList, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPostsSince", arg0, arg1, arg2) + ret0, _ := ret[0].(*model.PostList) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetPostsSince indicates an expected call of GetPostsSince +func (mr *MockClientMockRecorder) GetPostsSince(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostsSince", reflect.TypeOf((*MockClient)(nil).GetPostsSince), arg0, arg1, arg2) +} + // GetPrivateChannelsForTeam mocks base method func (m *MockClient) GetPrivateChannelsForTeam(arg0 string, arg1, arg2 int, arg3 string) ([]*model.Channel, *model.Response, error) { m.ctrl.T.Helper()