diff --git a/app/command.go b/app/command.go index 7a1fcee2c0..b6406a6038 100644 --- a/app/command.go +++ b/app/command.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "strings" + "sync" goi18n "github.com/mattermost/go-i18n/i18n" "github.com/mattermost/mattermost-server/v5/mlog" @@ -194,6 +195,117 @@ func (a *App) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, * return nil, model.NewAppError("command", "api.command.execute_command.not_found.app_error", map[string]interface{}{"Trigger": trigger}, "", http.StatusNotFound) } +// mentionsToTeamMembers returns all the @ mentions found in message that +// belong to users in the specified team, linking them to their users +func (a *App) mentionsToTeamMembers(message, teamId string) model.UserMentionMap { + type mentionMapItem struct { + Name string + Id string + } + + possibleMentions := model.PossibleAtMentions(message) + mentionChan := make(chan *mentionMapItem, len(possibleMentions)) + + var wg sync.WaitGroup + for _, mention := range possibleMentions { + wg.Add(1) + go func(mention string) { + defer wg.Done() + user, err := a.Srv().Store.User().GetByUsername(mention) + + if err != nil && err.StatusCode != http.StatusNotFound { + mlog.Warn("Failed to retrieve user @"+mention, mlog.Err(err)) + return + } + + // If it's a http.StatusNotFound error, check for usernames in substrings + // without trailing punctuation + if err != nil { + trimmed, ok := model.TrimUsernameSpecialChar(mention) + for ; ok; trimmed, ok = model.TrimUsernameSpecialChar(trimmed) { + userFromTrimmed, userErr := a.Srv().Store.User().GetByUsername(trimmed) + if userErr != nil && err.StatusCode != http.StatusNotFound { + return + } + + if userErr != nil { + continue + } + + _, err = a.GetTeamMember(teamId, userFromTrimmed.Id) + if err != nil { + // The user is not in the team, so we should ignore it + return + } + + mentionChan <- &mentionMapItem{trimmed, userFromTrimmed.Id} + return + } + + return + } + + _, err = a.GetTeamMember(teamId, user.Id) + if err != nil { + // The user is not in the team, so we should ignore it + return + } + + mentionChan <- &mentionMapItem{mention, user.Id} + }(mention) + } + + wg.Wait() + close(mentionChan) + + atMentionMap := make(model.UserMentionMap) + for mention := range mentionChan { + atMentionMap[mention.Name] = mention.Id + } + + return atMentionMap +} + +// mentionsToPublicChannels returns all the mentions to public channels, +// linking them to their channels +func (a *App) mentionsToPublicChannels(message, teamId string) model.ChannelMentionMap { + type mentionMapItem struct { + Name string + Id string + } + + channelMentions := model.ChannelMentions(message) + mentionChan := make(chan *mentionMapItem, len(channelMentions)) + + var wg sync.WaitGroup + for _, channelName := range channelMentions { + wg.Add(1) + go func(channelName string) { + defer wg.Done() + channel, err := a.GetChannelByName(channelName, teamId, false) + if err != nil { + return + } + + if !channel.IsOpen() { + return + } + + mentionChan <- &mentionMapItem{channelName, channel.Id} + }(channelName) + } + + wg.Wait() + close(mentionChan) + + channelMentionMap := make(model.ChannelMentionMap) + for mention := range mentionChan { + channelMentionMap[mention.Name] = mention.Id + } + + return channelMentionMap +} + // tryExecuteBuiltInCommand attempts to run a built in command based on the given arguments. If no such command can be // found, returns nil for all arguments. func (a *App) tryExecuteBuiltInCommand(args *model.CommandArgs, trigger string, message string) (*model.Command, *model.CommandResponse) { @@ -293,6 +405,16 @@ func (a *App) tryExecuteCustomCommand(args *model.CommandArgs, trigger string, m p.Set("trigger_id", args.TriggerId) + userMentionMap := a.mentionsToTeamMembers(message, team.Id) + for key, values := range userMentionMap.ToURLValues() { + p[key] = values + } + + channelMentionMap := a.mentionsToPublicChannels(message, team.Id) + for key, values := range channelMentionMap.ToURLValues() { + p[key] = values + } + hook, appErr := a.CreateCommandWebhook(cmd.Id, args) if appErr != nil { return cmd, nil, model.NewAppError("command", "api.command.execute_command.failed.app_error", map[string]interface{}{"Trigger": trigger}, appErr.Error(), http.StatusInternalServerError) diff --git a/app/command_test.go b/app/command_test.go index b6feed7c3c..4b7d23ee26 100644 --- a/app/command_test.go +++ b/app/command_test.go @@ -4,6 +4,7 @@ package app import ( + "fmt" "io" "net/http" "net/http/httptest" @@ -383,3 +384,174 @@ func TestDoCommandRequest(t *testing.T) { close(done) }) } + +func TestMentionsToTeamMembers(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + otherTeam := th.CreateTeam() + otherUser := th.CreateUser() + th.LinkUserToTeam(otherUser, otherTeam) + + fixture := []struct { + message string + inTeam string + expectedMap model.UserMentionMap + }{ + { + fmt.Sprintf(""), + th.BasicTeam.Id, + model.UserMentionMap{}, + }, + { + fmt.Sprintf("/trigger"), + th.BasicTeam.Id, + model.UserMentionMap{}, + }, + { + fmt.Sprintf("/trigger 0 mentions"), + th.BasicTeam.Id, + model.UserMentionMap{}, + }, + { + fmt.Sprintf("/trigger 1 valid user @%s", th.BasicUser.Username), + th.BasicTeam.Id, + model.UserMentionMap{th.BasicUser.Username: th.BasicUser.Id}, + }, + { + fmt.Sprintf("/trigger 2 valid users @%s @%s", + th.BasicUser.Username, th.BasicUser2.Username, + ), + th.BasicTeam.Id, + model.UserMentionMap{ + th.BasicUser.Username: th.BasicUser.Id, + th.BasicUser2.Username: th.BasicUser2.Id, + }, + }, + { + fmt.Sprintf("/trigger 1 user from another team @%s", otherUser.Username), + th.BasicTeam.Id, + model.UserMentionMap{}, + }, + { + fmt.Sprintf("/trigger 2 valid users + 1 from another team @%s @%s @%s", + th.BasicUser.Username, th.BasicUser2.Username, otherUser.Username, + ), + th.BasicTeam.Id, + model.UserMentionMap{ + th.BasicUser.Username: th.BasicUser.Id, + th.BasicUser2.Username: th.BasicUser2.Id, + }, + }, + { + fmt.Sprintf("/trigger a valid channel ~%s", th.BasicChannel.Name), + th.BasicTeam.Id, + model.UserMentionMap{}, + }, + { + fmt.Sprintf("/trigger channel and mentions ~%s @%s", + th.BasicChannel.Name, th.BasicUser.Username), + th.BasicTeam.Id, + model.UserMentionMap{th.BasicUser.Username: th.BasicUser.Id}, + }, + { + fmt.Sprintf("/trigger repeated users @%s @%s @%s", + th.BasicUser.Username, th.BasicUser2.Username, th.BasicUser.Username), + th.BasicTeam.Id, + model.UserMentionMap{ + th.BasicUser.Username: th.BasicUser.Id, + th.BasicUser2.Username: th.BasicUser2.Id, + }, + }, + } + + for _, data := range fixture { + actualMap := th.App.mentionsToTeamMembers(data.message, data.inTeam) + require.Equal(t, actualMap, data.expectedMap) + } +} + +func TestMentionsToPublicChannels(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + otherPublicChannel := th.CreateChannel(th.BasicTeam) + privateChannel := th.CreatePrivateChannel(th.BasicTeam) + + fixture := []struct { + message string + inTeam string + expectedMap model.ChannelMentionMap + }{ + { + fmt.Sprintf(""), + th.BasicTeam.Id, + model.ChannelMentionMap{}, + }, + { + fmt.Sprintf("/trigger"), + th.BasicTeam.Id, + model.ChannelMentionMap{}, + }, + { + fmt.Sprintf("/trigger 0 mentions"), + th.BasicTeam.Id, + model.ChannelMentionMap{}, + }, + { + fmt.Sprintf("/trigger 1 public channel ~%s", th.BasicChannel.Name), + th.BasicTeam.Id, + model.ChannelMentionMap{th.BasicChannel.Name: th.BasicChannel.Id}, + }, + { + fmt.Sprintf("/trigger 2 public channels ~%s ~%s", + th.BasicChannel.Name, otherPublicChannel.Name, + ), + th.BasicTeam.Id, + model.ChannelMentionMap{ + th.BasicChannel.Name: th.BasicChannel.Id, + otherPublicChannel.Name: otherPublicChannel.Id, + }, + }, + { + fmt.Sprintf("/trigger 1 private channel ~%s", privateChannel.Name), + th.BasicTeam.Id, + model.ChannelMentionMap{}, + }, + { + fmt.Sprintf("/trigger 2 public channel + 1 private ~%s ~%s ~%s", + th.BasicChannel.Name, otherPublicChannel.Name, privateChannel.Name, + ), + th.BasicTeam.Id, + model.ChannelMentionMap{ + th.BasicChannel.Name: th.BasicChannel.Id, + otherPublicChannel.Name: otherPublicChannel.Id, + }, + }, + { + fmt.Sprintf("/trigger a valid user @%s", th.BasicUser.Username), + th.BasicTeam.Id, + model.ChannelMentionMap{}, + }, + { + fmt.Sprintf("/trigger channel and mentions ~%s @%s", + th.BasicChannel.Name, th.BasicUser.Username), + th.BasicTeam.Id, + model.ChannelMentionMap{th.BasicChannel.Name: th.BasicChannel.Id}, + }, + { + fmt.Sprintf("/trigger repeated channels ~%s ~%s ~%s", + th.BasicChannel.Name, otherPublicChannel.Name, th.BasicChannel.Name), + th.BasicTeam.Id, + model.ChannelMentionMap{ + th.BasicChannel.Name: th.BasicChannel.Id, + otherPublicChannel.Name: otherPublicChannel.Id, + }, + }, + } + + for _, data := range fixture { + actualMap := th.App.mentionsToPublicChannels(data.message, data.inTeam) + require.Equal(t, actualMap, data.expectedMap) + } +} diff --git a/app/plugin_commands.go b/app/plugin_commands.go index 75df9a0aff..e41ffe7677 100644 --- a/app/plugin_commands.go +++ b/app/plugin_commands.go @@ -120,6 +120,14 @@ func (a *App) tryExecutePluginCommand(args *model.CommandArgs) (*model.Command, return matched.Command, nil, model.NewAppError("ExecutePluginCommand", "model.plugin_command.error.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) } + for username, userId := range a.mentionsToTeamMembers(args.Command, args.TeamId) { + args.AddUserMention(username, userId) + } + + for channelName, channelId := range a.mentionsToPublicChannels(args.Command, args.TeamId) { + args.AddChannelMention(channelName, channelId) + } + response, appErr := pluginHooks.ExecuteCommand(a.PluginContext(), args) return matched.Command, response, appErr } diff --git a/model/at_mentions.go b/model/at_mentions.go new file mode 100644 index 0000000000..f41d182ad3 --- /dev/null +++ b/model/at_mentions.go @@ -0,0 +1,47 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "regexp" + "strings" +) + +var atMentionRegexp = regexp.MustCompile(`\B@[[:alnum:]][[:alnum:]\.\-_]*`) + +const usernameSpecialChars = ".-_" + +// PossibleAtMentions returns all substrings in message that look like valid @ +// mentions. +func PossibleAtMentions(message string) []string { + var names []string + + if !strings.Contains(message, "@") { + return names + } + + alreadyMentioned := make(map[string]bool) + for _, match := range atMentionRegexp.FindAllString(message, -1) { + name := NormalizeUsername(match[1:]) + if !alreadyMentioned[name] && IsValidUsername(name) { + names = append(names, name) + alreadyMentioned[name] = true + } + } + + return names +} + +// TrimUsernameSpecialChar tries to remove the last character from word if it +// is a special character for usernames (dot, dash or underscore). If not, it +// returns the same string. +func TrimUsernameSpecialChar(word string) (string, bool) { + len := len(word) + + if len > 0 && strings.LastIndexAny(word, usernameSpecialChars) == (len-1) { + return word[:len-1], true + } + + return word, false +} diff --git a/model/at_mentions_test.go b/model/at_mentions_test.go new file mode 100644 index 0000000000..39c8251984 --- /dev/null +++ b/model/at_mentions_test.go @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPossibleAtMentions(t *testing.T) { + fixture := []struct { + message string + expected []string + }{ + { + "", + []string{}, + }, + { + "@user", + []string{"user"}, + }, + { + "@user-with_special.chars @multiple.-_chars", + []string{"user-with_special.chars", "multiple.-_chars"}, + }, + { + "@repeated @user @repeated", + []string{"repeated", "user"}, + }, + { + "@user1 @user2 @user3", + []string{"user1", "user2", "user3"}, + }, + { + "@李", + []string{}, + }, + { + "@withfinaldot. @withfinaldash- @withfinalunderscore_", + []string{ + "withfinaldot.", + "withfinaldash-", + "withfinalunderscore_", + }, + }, + } + + for _, data := range fixture { + actual := PossibleAtMentions(data.message) + require.ElementsMatch(t, actual, data.expected) + } +} + +func TestTrimUsernameSpecialChar(t *testing.T) { + fixture := []struct { + word string + expectedString string + expectedBool bool + }{ + {"user...", "user..", true}, + {"user..", "user.", true}, + {"user.", "user", true}, + {"user--", "user-", true}, + {"user-", "user", true}, + {"user_.-", "user_.", true}, + {"user_.", "user_", true}, + {"user_", "user", true}, + {"user", "user", false}, + {"user.with-inner_chars", "user.with.inner.chars", false}, + } + + for _, data := range fixture { + actualString, actualBool := TrimUsernameSpecialChar(data.word) + require.Equal(t, actualBool, data.expectedBool) + if actualBool { + require.Equal(t, actualString, data.expectedString) + } else { + require.Equal(t, actualString, data.word) + } + } +} diff --git a/model/channel.go b/model/channel.go index 76421789b1..b10352434c 100644 --- a/model/channel.go +++ b/model/channel.go @@ -243,6 +243,10 @@ func (o *Channel) IsGroupOrDirect() bool { return o.Type == CHANNEL_DIRECT || o.Type == CHANNEL_GROUP } +func (o *Channel) IsOpen() bool { + return o.Type == CHANNEL_OPEN +} + func (o *Channel) Patch(patch *ChannelPatch) { if patch.DisplayName != nil { o.DisplayName = *patch.DisplayName diff --git a/model/command_args.go b/model/command_args.go index 1faba18c66..a3bbb4c9d1 100644 --- a/model/command_args.go +++ b/model/command_args.go @@ -11,16 +11,18 @@ import ( ) type CommandArgs struct { - UserId string `json:"user_id"` - ChannelId string `json:"channel_id"` - TeamId string `json:"team_id"` - RootId string `json:"root_id"` - ParentId string `json:"parent_id"` - TriggerId string `json:"trigger_id,omitempty"` - Command string `json:"command"` - SiteURL string `json:"-"` - T goi18n.TranslateFunc `json:"-"` - Session Session `json:"-"` + UserId string `json:"user_id"` + ChannelId string `json:"channel_id"` + TeamId string `json:"team_id"` + RootId string `json:"root_id"` + ParentId string `json:"parent_id"` + TriggerId string `json:"trigger_id,omitempty"` + Command string `json:"command"` + SiteURL string `json:"-"` + T goi18n.TranslateFunc `json:"-"` + Session Session `json:"-"` + UserMentions UserMentionMap `json:"-"` + ChannelMentions ChannelMentionMap `json:"-"` } func (o *CommandArgs) ToJson() string { @@ -33,3 +35,23 @@ func CommandArgsFromJson(data io.Reader) *CommandArgs { json.NewDecoder(data).Decode(&o) return o } + +// AddUserMention adds or overrides an entry in UserMentions with name username +// and identifier userId +func (o *CommandArgs) AddUserMention(username, userId string) { + if o.UserMentions == nil { + o.UserMentions = make(UserMentionMap) + } + + o.UserMentions[username] = userId +} + +// AddChannelMention adds or overrides an entry in ChannelMentions with name +// channelName and identifier channelId +func (o *CommandArgs) AddChannelMention(channelName, channelId string) { + if o.ChannelMentions == nil { + o.ChannelMentions = make(ChannelMentionMap) + } + + o.ChannelMentions[channelName] = channelId +} diff --git a/model/command_args_test.go b/model/command_args_test.go new file mode 100644 index 0000000000..7f4613eb29 --- /dev/null +++ b/model/command_args_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCommandArgs_AddUserMention(t *testing.T) { + fixture := []struct { + args CommandArgs + mentions map[string]string + expected CommandArgs + }{ + { + CommandArgs{}, + map[string]string{"one": "1"}, + CommandArgs{ + UserMentions: map[string]string{"one": "1"}, + }, + }, + { + CommandArgs{ + ChannelMentions: map[string]string{"channel": "1"}, + }, + map[string]string{"one": "1"}, + CommandArgs{ + UserMentions: map[string]string{"one": "1"}, + ChannelMentions: map[string]string{"channel": "1"}, + }, + }, + { + CommandArgs{ + UserMentions: map[string]string{"one": "1"}, + }, + map[string]string{"one": "1"}, + CommandArgs{ + UserMentions: map[string]string{"one": "1"}, + }, + }, + { + CommandArgs{}, + map[string]string{"one": "1", "two": "2", "three": "3"}, + CommandArgs{ + UserMentions: map[string]string{"one": "1", "two": "2", "three": "3"}, + }, + }, + } + + for _, data := range fixture { + for name, id := range data.mentions { + data.args.AddUserMention(name, id) + } + require.Equal(t, data.args, data.expected) + } +} + +func TestCommandArgs_AddChannelMention(t *testing.T) { + fixture := []struct { + args CommandArgs + mentions map[string]string + expected CommandArgs + }{ + { + CommandArgs{}, + map[string]string{"one": "1"}, + CommandArgs{ + ChannelMentions: map[string]string{"one": "1"}, + }, + }, + { + CommandArgs{ + UserMentions: map[string]string{"user": "1"}, + }, + map[string]string{"one": "1"}, + CommandArgs{ + ChannelMentions: map[string]string{"one": "1"}, + UserMentions: map[string]string{"user": "1"}, + }, + }, + { + CommandArgs{ + ChannelMentions: map[string]string{"one": "1"}, + }, + map[string]string{"one": "1"}, + CommandArgs{ + ChannelMentions: map[string]string{"one": "1"}, + }, + }, + { + CommandArgs{}, + map[string]string{"one": "1", "two": "2", "three": "3"}, + CommandArgs{ + ChannelMentions: map[string]string{"one": "1", "two": "2", "three": "3"}, + }, + }, + } + + for _, data := range fixture { + for name, id := range data.mentions { + data.args.AddChannelMention(name, id) + } + require.Equal(t, data.args, data.expected) + } +} diff --git a/model/mention_map.go b/model/mention_map.go new file mode 100644 index 0000000000..2f3444dd2f --- /dev/null +++ b/model/mention_map.go @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "fmt" + "net/url" +) + +type UserMentionMap map[string]string +type ChannelMentionMap map[string]string + +const ( + userMentionsKey = "user_mentions" + userMentionsIdsKey = "user_mentions_ids" + channelMentionsKey = "channel_mentions" + channelMentionsIdsKey = "channel_mentions_ids" +) + +func UserMentionMapFromURLValues(values url.Values) (UserMentionMap, error) { + return mentionsFromURLValues(values, userMentionsKey, userMentionsIdsKey) +} + +func (m UserMentionMap) ToURLValues() url.Values { + return mentionsToURLValues(m, userMentionsKey, userMentionsIdsKey) +} + +func ChannelMentionMapFromURLValues(values url.Values) (ChannelMentionMap, error) { + return mentionsFromURLValues(values, channelMentionsKey, channelMentionsIdsKey) +} + +func (m ChannelMentionMap) ToURLValues() url.Values { + return mentionsToURLValues(m, channelMentionsKey, channelMentionsIdsKey) +} + +func mentionsFromURLValues(values url.Values, mentionKey, idKey string) (map[string]string, error) { + mentions, mentionsOk := values[mentionKey] + ids, idsOk := values[idKey] + + if !mentionsOk && !idsOk { + return map[string]string{}, nil + } + + if !mentionsOk { + return nil, fmt.Errorf("%s key not found", mentionKey) + } + + if !idsOk { + return nil, fmt.Errorf("%s key not found", idKey) + } + + if len(mentions) != len(ids) { + return nil, fmt.Errorf("keys %s and %s have different length", mentionKey, idKey) + } + + mentionsMap := make(map[string]string) + for i, mention := range mentions { + id := ids[i] + + if oldId, ok := mentionsMap[mention]; ok && oldId != id { + return nil, fmt.Errorf("key %s has two different values: %s and %s", mention, oldId, id) + } + + mentionsMap[mention] = id + } + + return mentionsMap, nil +} + +func mentionsToURLValues(mentions map[string]string, mentionKey, idKey string) url.Values { + values := url.Values{} + + for mention, id := range mentions { + values.Add(mentionKey, mention) + values.Add(idKey, id) + } + + return values +} diff --git a/model/mention_map_test.go b/model/mention_map_test.go new file mode 100644 index 0000000000..12132a5927 --- /dev/null +++ b/model/mention_map_test.go @@ -0,0 +1,235 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUserMentionMapFromURLValues(t *testing.T) { + fixture := []struct { + values url.Values + expected UserMentionMap + error bool + }{ + { + url.Values{}, + UserMentionMap{}, + false, + }, + { + url.Values{ + userMentionsKey: []string{}, + userMentionsIdsKey: []string{}, + }, + UserMentionMap{}, + false, + }, + { + url.Values{ + userMentionsKey: []string{"one", "two", "three"}, + userMentionsIdsKey: []string{"oneId", "twoId", "threeId"}, + }, + UserMentionMap{ + "one": "oneId", + "two": "twoId", + "three": "threeId", + }, + false, + }, + { + url.Values{ + "wrongKey": []string{"one", "two", "three"}, + userMentionsIdsKey: []string{"oneId", "twoId", "threeId"}, + }, + nil, + true, + }, + { + url.Values{ + userMentionsKey: []string{"one", "two", "three"}, + "wrongKey": []string{"oneId", "twoId", "threeId"}, + }, + nil, + true, + }, + { + url.Values{ + userMentionsKey: []string{"one", "two"}, + userMentionsIdsKey: []string{"justone"}, + }, + nil, + true, + }, + } + + for _, data := range fixture { + actualMap, actualError := UserMentionMapFromURLValues(data.values) + if data.error { + require.Error(t, actualError) + require.Nil(t, actualMap) + } else { + require.NoError(t, actualError) + require.Equal(t, actualMap, data.expected) + } + } +} + +func TestUserMentionMap_ToURLValues(t *testing.T) { + fixture := []struct { + mentionMap UserMentionMap + expected url.Values + }{ + { + UserMentionMap{}, + url.Values{}, + }, + { + UserMentionMap{"user": "id"}, + url.Values{ + userMentionsKey: []string{"user"}, + userMentionsIdsKey: []string{"id"}, + }, + }, + { + UserMentionMap{"one": "id1", "two": "id2", "three": "id3"}, + url.Values{ + userMentionsKey: []string{"one", "two", "three"}, + userMentionsIdsKey: []string{"id1", "id2", "id3"}, + }, + }, + } + + for _, data := range fixture { + actualValues := data.mentionMap.ToURLValues() + + // require.EqualValues does not work here directly on the url.Values, as + // the slices in the map values may be in different order; what we need to + // check is that the pairs are preserved, which can be checked converting + // back to a map with FromURLValues. We check that the test is well-formed + // by converting back the expected url.Values too. + require.Equal(t, len(actualValues), len(data.expected)) + + actualMentionMap, actualErr := UserMentionMapFromURLValues(actualValues) + expectedMentionMap, expectedErr := UserMentionMapFromURLValues(data.expected) + + require.Equal(t, actualErr, expectedErr) + require.Equal(t, actualMentionMap, expectedMentionMap) + } +} + +func TestChannelMentionMapFromURLValues(t *testing.T) { + fixture := []struct { + values url.Values + expected ChannelMentionMap + error bool + }{ + { + url.Values{}, + ChannelMentionMap{}, + false, + }, + { + url.Values{ + channelMentionsKey: []string{}, + channelMentionsIdsKey: []string{}, + }, + ChannelMentionMap{}, + false, + }, + { + url.Values{ + channelMentionsKey: []string{"one", "two", "three"}, + channelMentionsIdsKey: []string{"oneId", "twoId", "threeId"}, + }, + ChannelMentionMap{ + "one": "oneId", + "two": "twoId", + "three": "threeId", + }, + false, + }, + { + url.Values{ + "wrongKey": []string{"one", "two", "three"}, + channelMentionsIdsKey: []string{"oneId", "twoId", "threeId"}, + }, + nil, + true, + }, + { + url.Values{ + channelMentionsKey: []string{"one", "two", "three"}, + "wrongKey": []string{"oneId", "twoId", "threeId"}, + }, + nil, + true, + }, + { + url.Values{ + channelMentionsKey: []string{"one", "two"}, + channelMentionsIdsKey: []string{"justone"}, + }, + nil, + true, + }, + } + + for _, data := range fixture { + actualMap, actualError := ChannelMentionMapFromURLValues(data.values) + if data.error { + require.Error(t, actualError) + require.Nil(t, actualMap) + } else { + require.NoError(t, actualError) + require.Equal(t, actualMap, data.expected) + } + } +} + +func TestChannelMentionMap_ToURLValues(t *testing.T) { + fixture := []struct { + mentionMap ChannelMentionMap + expected url.Values + }{ + { + ChannelMentionMap{}, + url.Values{}, + }, + { + ChannelMentionMap{"user": "id"}, + url.Values{ + channelMentionsKey: []string{"user"}, + channelMentionsIdsKey: []string{"id"}, + }, + }, + { + ChannelMentionMap{"one": "id1", "two": "id2", "three": "id3"}, + url.Values{ + channelMentionsKey: []string{"one", "two", "three"}, + channelMentionsIdsKey: []string{"id1", "id2", "id3"}, + }, + }, + } + + for _, data := range fixture { + actualValues := data.mentionMap.ToURLValues() + + // require.EqualValues does not work here directly on the url.Values, as + // the slices in the map values may be in different order; what we need to + // check is that the pairs are preserved, which can be checked converting + // back to a map with FromURLValues. We check that the test is well-formed + // by converting back the expected url.Values too. + require.Equal(t, len(actualValues), len(data.expected)) + + actualMentionMap, actualErr := ChannelMentionMapFromURLValues(actualValues) + expectedMentionMap, expectedErr := ChannelMentionMapFromURLValues(data.expected) + + require.Equal(t, actualErr, expectedErr) + require.Equal(t, actualMentionMap, expectedMentionMap) + } +}