github.com/keybase/client/go@v0.0.0-20241007131713-f10651d043c8/chat/emojisource_test.go (about)

     1  package chat
     2  
     3  import (
     4  	"context"
     5  	"encoding/base64"
     6  	"encoding/json"
     7  	"net/http"
     8  	"os"
     9  	"regexp"
    10  	"strings"
    11  	"testing"
    12  	"time"
    13  
    14  	"github.com/keybase/client/go/kbtest"
    15  
    16  	"github.com/keybase/client/go/chat/attachments"
    17  	"github.com/keybase/client/go/chat/types"
    18  	"github.com/keybase/client/go/chat/utils"
    19  	"github.com/keybase/client/go/kbhttp/manager"
    20  	"github.com/keybase/client/go/protocol/chat1"
    21  	"github.com/keybase/client/go/protocol/gregor1"
    22  	"github.com/stretchr/testify/require"
    23  )
    24  
    25  var decorateBegin = "$>kb$"
    26  var decorateEnd = "$<kb$"
    27  
    28  func checkEmoji(ctx context.Context, t *testing.T, tc *kbtest.ChatTestContext,
    29  	uid gregor1.UID, conv chat1.ConversationInfoLocal, msgID chat1.MessageID, emoji string) {
    30  	msg, err := tc.Context().ConvSource.GetMessage(ctx, conv.Id, uid, msgID, nil, nil, true)
    31  	require.NoError(t, err)
    32  	require.True(t, msg.IsValid())
    33  	require.Equal(t, 1, len(msg.Valid().Emojis))
    34  	require.Equal(t, emoji, msg.Valid().Emojis[0].Alias)
    35  	uimsg := utils.PresentMessageUnboxed(ctx, tc.Context(), msg, uid, conv.Id)
    36  	require.True(t, uimsg.IsValid())
    37  	require.NotNil(t, uimsg.Valid().DecoratedTextBody)
    38  	checker := regexp.MustCompile(utils.ServiceDecorationPrefix)
    39  	require.True(t, checker.Match([]byte(*uimsg.Valid().DecoratedTextBody)))
    40  	payload := strings.ReplaceAll(*uimsg.Valid().DecoratedTextBody, decorateBegin, "")
    41  	payload = strings.ReplaceAll(payload, decorateEnd, "")
    42  	t.Logf("payload: %s", payload)
    43  	dat, err := base64.StdEncoding.DecodeString(payload)
    44  	require.NoError(t, err)
    45  	var dec chat1.UITextDecoration
    46  	require.NoError(t, json.Unmarshal(dat, &dec))
    47  	typ, err := dec.Typ()
    48  	require.NoError(t, err)
    49  	require.Equal(t, chat1.UITextDecorationTyp_EMOJI, typ)
    50  	require.True(t, dec.Emoji().Source.IsHTTPSrv())
    51  	resp, err := http.Get(dec.Emoji().Source.Httpsrv())
    52  	require.NoError(t, err)
    53  	defer resp.Body.Close()
    54  	require.Equal(t, http.StatusOK, resp.StatusCode)
    55  }
    56  
    57  func TestEmojiSourceBasic(t *testing.T) {
    58  	useRemoteMock = false
    59  	defer func() { useRemoteMock = true }()
    60  	ctc := makeChatTestContext(t, "TestEmojiSourceBasic", 1)
    61  	defer ctc.cleanup()
    62  
    63  	users := ctc.users()
    64  	uid := users[0].User.GetUID().ToBytes()
    65  	tc := ctc.world.Tcs[users[0].Username]
    66  	ctx := ctc.as(t, users[0]).startCtx
    67  	ri := ctc.as(t, users[0]).ri
    68  	tc.ChatG.AttachmentURLSrv = NewAttachmentHTTPSrv(tc.Context(),
    69  		manager.NewSrv(tc.Context().ExternalG()),
    70  		types.DummyAttachmentFetcher{},
    71  		func() chat1.RemoteInterface { return ri })
    72  	store := attachments.NewStoreTesting(tc.Context(), nil)
    73  	uploader := attachments.NewUploader(tc.Context(), store, mockSigningRemote{},
    74  		func() chat1.RemoteInterface { return ri }, 1)
    75  	tc.ChatG.AttachmentUploader = uploader
    76  	filename := "./testdata/party_parrot.gif"
    77  	tc.ChatG.EmojiSource.(*DevConvEmojiSource).tempDir = os.TempDir()
    78  
    79  	conv := mustCreateConversationForTest(t, ctc, users[0], chat1.TopicType_CHAT,
    80  		chat1.ConversationMembersType_IMPTEAMNATIVE)
    81  
    82  	t.Logf("admin")
    83  	source, err := tc.Context().EmojiSource.Add(ctx, uid, conv.Id, "party_parrot", filename, false)
    84  	require.NoError(t, err)
    85  	_, err = tc.Context().EmojiSource.Add(ctx, uid, conv.Id, "+1", filename, false)
    86  	require.NoError(t, err)
    87  
    88  	teamConv := mustCreateConversationForTest(t, ctc, users[0], chat1.TopicType_CHAT,
    89  		chat1.ConversationMembersType_TEAM)
    90  
    91  	_, err = tc.Context().EmojiSource.Add(ctx, uid, teamConv.Id, "mike2", filename, false)
    92  	require.NoError(t, err)
    93  	_, err = tc.Context().EmojiSource.Add(ctx, uid, teamConv.Id, "party_parrot2", filename, false)
    94  	require.NoError(t, err)
    95  
    96  	res, err := tc.Context().EmojiSource.Get(ctx, uid, nil, chat1.EmojiFetchOpts{
    97  		GetCreationInfo: true,
    98  		GetAliases:      true,
    99  	})
   100  	require.NoError(t, err)
   101  	require.Equal(t, 2, len(res.Emojis))
   102  	for _, group := range res.Emojis {
   103  		require.True(t, group.Name == conv.TlfName || group.Name == teamConv.TlfName)
   104  		require.Equal(t, 2, len(group.Emojis))
   105  		for _, emoji := range group.Emojis {
   106  			require.True(t, emoji.Alias == "+1#2" || emoji.Alias == "party_parrot" ||
   107  				emoji.Alias == "mike2" || emoji.Alias == "party_parrot2", emoji.Alias)
   108  			styp, err := emoji.Source.Typ()
   109  			require.NoError(t, err)
   110  			require.Equal(t, chat1.EmojiLoadSourceTyp_HTTPSRV, styp)
   111  			require.NotZero(t, len(emoji.Source.Httpsrv()))
   112  		}
   113  	}
   114  
   115  	t.Logf("decorate")
   116  	msgID := mustPostLocalForTest(t, ctc, users[0], conv, chat1.NewMessageBodyWithText(chat1.MessageText{
   117  		Body: ":party_parrot:",
   118  	}))
   119  	checkEmoji(ctx, t, tc, uid, conv, msgID, "party_parrot")
   120  
   121  	t.Logf("remove")
   122  	_, err = tc.Context().ConvSource.GetMessage(ctx, source.Message().ConvID, uid, source.Message().MsgID,
   123  		nil, nil, true)
   124  	require.NoError(t, err)
   125  	require.NoError(t, tc.Context().EmojiSource.Remove(ctx, uid, conv.Id, "party_parrot"))
   126  	require.True(t, source.IsMessage())
   127  	_, err = tc.Context().ConvSource.GetMessage(ctx, source.Message().ConvID, uid, source.Message().MsgID,
   128  		nil, nil, true)
   129  	require.Error(t, err)
   130  	res, err = tc.Context().EmojiSource.Get(ctx, uid, &conv.Id, chat1.EmojiFetchOpts{
   131  		GetCreationInfo: true,
   132  		GetAliases:      true,
   133  	})
   134  	require.NoError(t, err)
   135  	checked := false
   136  	for _, group := range res.Emojis {
   137  		if group.Name == conv.TlfName {
   138  			require.Equal(t, 1, len(group.Emojis))
   139  			checked = true
   140  		}
   141  	}
   142  	require.True(t, checked)
   143  
   144  	t.Logf("alias")
   145  	_, err = tc.Context().EmojiSource.AddAlias(ctx, uid, conv.Id, "mike2", "+1")
   146  	require.NoError(t, err)
   147  	res, err = tc.Context().EmojiSource.Get(ctx, uid, &conv.Id, chat1.EmojiFetchOpts{
   148  		GetCreationInfo: true,
   149  		GetAliases:      true,
   150  	})
   151  	require.NoError(t, err)
   152  	checked = false
   153  	for _, group := range res.Emojis {
   154  		if group.Name == conv.TlfName {
   155  			require.Equal(t, 2, len(group.Emojis))
   156  			checked = true
   157  		}
   158  	}
   159  	require.True(t, checked)
   160  	msgID = mustPostLocalForTest(t, ctc, users[0], conv, chat1.NewMessageBodyWithText(chat1.MessageText{
   161  		Body: ":mike2:",
   162  	}))
   163  	checkEmoji(ctx, t, tc, uid, conv, msgID, "mike2")
   164  	require.NoError(t, tc.Context().EmojiSource.Remove(ctx, uid, conv.Id, "mike2"))
   165  	res, err = tc.Context().EmojiSource.Get(ctx, uid, &conv.Id, chat1.EmojiFetchOpts{
   166  		GetCreationInfo: true,
   167  		GetAliases:      true,
   168  	})
   169  	require.NoError(t, err)
   170  	checked = false
   171  	for _, group := range res.Emojis {
   172  		if group.Name == conv.TlfName {
   173  			t.Logf("emojis: %+v", group.Emojis)
   174  			require.Equal(t, 1, len(group.Emojis))
   175  			checked = true
   176  		}
   177  	}
   178  	require.True(t, checked)
   179  	msgID = mustPostLocalForTest(t, ctc, users[0], conv, chat1.NewMessageBodyWithText(chat1.MessageText{
   180  		Body: ":+1#2:",
   181  	}))
   182  	checkEmoji(ctx, t, tc, uid, conv, msgID, "+1#2")
   183  	_, err = tc.Context().EmojiSource.AddAlias(ctx, uid, conv.Id, "mike2", "+1")
   184  	require.NoError(t, err)
   185  	require.NoError(t, tc.Context().EmojiSource.Remove(ctx, uid, conv.Id, "+1"))
   186  	res, err = tc.Context().EmojiSource.Get(ctx, uid, &conv.Id, chat1.EmojiFetchOpts{
   187  		GetCreationInfo: true,
   188  		GetAliases:      true,
   189  	})
   190  	require.NoError(t, err)
   191  	checked = false
   192  	for _, group := range res.Emojis {
   193  		if group.Name == conv.TlfName {
   194  			require.Zero(t, len(group.Emojis))
   195  			checked = true
   196  		}
   197  	}
   198  	require.True(t, checked)
   199  
   200  	t.Logf("stock alias")
   201  	_, err = tc.Context().EmojiSource.AddAlias(ctx, uid, conv.Id, ":my+1:", ":+1::skin-tone-0:")
   202  	require.NoError(t, err)
   203  	res, err = tc.Context().EmojiSource.Get(ctx, uid, &conv.Id, chat1.EmojiFetchOpts{
   204  		GetCreationInfo: true,
   205  		GetAliases:      true,
   206  	})
   207  	require.NoError(t, err)
   208  	checked = false
   209  	for _, group := range res.Emojis {
   210  		if group.Name == conv.TlfName {
   211  			require.Len(t, group.Emojis, 1)
   212  			emoji := group.Emojis[0]
   213  			require.Equal(t, chat1.Emoji{
   214  				Alias:        ":my+1:",
   215  				IsBig:        false,
   216  				IsReacji:     false,
   217  				IsCrossTeam:  false,
   218  				IsAlias:      true,
   219  				Teamname:     &conv.TlfName,
   220  				Source:       chat1.NewEmojiLoadSourceWithStr(":+1::skin-tone-0:"),
   221  				NoAnimSource: chat1.NewEmojiLoadSourceWithStr(":+1::skin-tone-0:"),
   222  				RemoteSource: chat1.NewEmojiRemoteSourceWithStockalias(chat1.EmojiStockAlias{
   223  					Text:     ":+1::skin-tone-0:",
   224  					Username: users[0].Username,
   225  					Time:     gregor1.ToTime(ctc.world.Fc.Now()),
   226  				}),
   227  				CreationInfo: &chat1.EmojiCreationInfo{
   228  					Username: users[0].Username,
   229  					Time:     gregor1.ToTime(ctc.world.Fc.Now()),
   230  				},
   231  			}, emoji)
   232  			checked = true
   233  		}
   234  	}
   235  	require.True(t, checked)
   236  }
   237  
   238  type emojiAliasTestCase struct {
   239  	input, output string
   240  	emojis        []chat1.HarvestedEmoji
   241  }
   242  
   243  func TestEmojiSourceAliasDecorate(t *testing.T) {
   244  	useRemoteMock = false
   245  	defer func() { useRemoteMock = true }()
   246  	ctc := makeChatTestContext(t, "TestEmojiSourceAliasDecorate", 1)
   247  	defer ctc.cleanup()
   248  
   249  	users := ctc.users()
   250  	uid := users[0].User.GetUID().ToBytes()
   251  	tc := ctc.world.Tcs[users[0].Username]
   252  	ctx := ctc.as(t, users[0]).startCtx
   253  
   254  	source := tc.Context().EmojiSource.(*DevConvEmojiSource)
   255  	testCases := []emojiAliasTestCase{
   256  		{
   257  			input:  "this is a test! :my+1: <- thumbs up",
   258  			output: "this is a test! :+1::skin-tone-0: <- thumbs up",
   259  			emojis: []chat1.HarvestedEmoji{
   260  				{
   261  					Alias: "my+1",
   262  					Source: chat1.NewEmojiRemoteSourceWithStockalias(chat1.EmojiStockAlias{
   263  						Text: ":+1::skin-tone-0:",
   264  					}),
   265  				}},
   266  		},
   267  		{
   268  			input:  ":my+1: <- :nothing: dksjdksdj :: :alias:",
   269  			output: ":+1::skin-tone-0: <- :nothing: dksjdksdj :: :karen:",
   270  			emojis: []chat1.HarvestedEmoji{
   271  				{
   272  					Alias: "my+1",
   273  					Source: chat1.NewEmojiRemoteSourceWithStockalias(chat1.EmojiStockAlias{
   274  						Text: ":+1::skin-tone-0:",
   275  					}),
   276  				},
   277  				{
   278  					Alias: "alias",
   279  					Source: chat1.NewEmojiRemoteSourceWithStockalias(chat1.EmojiStockAlias{
   280  						Text: ":karen:",
   281  					}),
   282  				},
   283  			},
   284  		},
   285  		{
   286  			input:  ":nothing: dskjdksdjs ::: :my+1: <- :nothing: dksjdksdj :: :alias: !!",
   287  			output: ":nothing: dskjdksdjs ::: :+1::skin-tone-0: <- :nothing: dksjdksdj :: :karen: !!",
   288  			emojis: []chat1.HarvestedEmoji{
   289  				{
   290  					Alias: "my+1",
   291  					Source: chat1.NewEmojiRemoteSourceWithStockalias(chat1.EmojiStockAlias{
   292  						Text: ":+1::skin-tone-0:",
   293  					}),
   294  				},
   295  				{
   296  					Alias: "alias",
   297  					Source: chat1.NewEmojiRemoteSourceWithStockalias(chat1.EmojiStockAlias{
   298  						Text: ":karen:",
   299  					}),
   300  				},
   301  			},
   302  		},
   303  	}
   304  	for _, testCase := range testCases {
   305  		output := source.Decorate(ctx, testCase.input, uid, chat1.MessageType_TEXT, testCase.emojis)
   306  		require.Equal(t, testCase.output, output)
   307  	}
   308  }
   309  
   310  func TestEmojiSourceCrossTeam(t *testing.T) {
   311  	useRemoteMock = false
   312  	defer func() { useRemoteMock = true }()
   313  	ctc := makeChatTestContext(t, "TestEmojiSourceCrossTeam", 4)
   314  	defer ctc.cleanup()
   315  
   316  	users := ctc.users()
   317  	uid := users[0].User.GetUID().ToBytes()
   318  	uid1 := gregor1.UID(users[1].User.GetUID().ToBytes())
   319  	tc := ctc.world.Tcs[users[0].Username]
   320  	tc1 := ctc.world.Tcs[users[1].Username]
   321  	ctx := ctc.as(t, users[0]).startCtx
   322  	ctx1 := ctc.as(t, users[1]).startCtx
   323  	ri := ctc.as(t, users[0]).ri
   324  	ri1 := ctc.as(t, users[1]).ri
   325  	store := attachments.NewStoreTesting(tc.Context(), nil)
   326  	fetcher := NewRemoteAttachmentFetcher(tc.Context(), store)
   327  	source := tc.Context().EmojiSource.(*DevConvEmojiSource)
   328  	source1 := tc1.Context().EmojiSource.(*DevConvEmojiSource)
   329  	source.tempDir = os.TempDir()
   330  	source1.tempDir = os.TempDir()
   331  	syncCreated := make(chan struct{}, 10)
   332  	syncRefresh := make(chan struct{}, 10)
   333  	source.testingCreatedSyncConv = syncCreated
   334  	source.testingRefreshedSyncConv = syncRefresh
   335  	timeout := 2 * time.Second
   336  	expectCreated := func(expect bool) {
   337  		if expect {
   338  			select {
   339  			case <-syncCreated:
   340  			case <-time.After(timeout):
   341  				require.Fail(t, "no sync created")
   342  			}
   343  		} else {
   344  			time.Sleep(100 * time.Millisecond)
   345  			select {
   346  			case <-syncCreated:
   347  				require.Fail(t, "no sync created expected")
   348  			default:
   349  			}
   350  		}
   351  	}
   352  	expectRefresh := func(expect bool) {
   353  		if expect {
   354  			select {
   355  			case <-syncRefresh:
   356  			case <-time.After(timeout):
   357  				require.Fail(t, "no sync refresh")
   358  			}
   359  		} else {
   360  			time.Sleep(100 * time.Millisecond)
   361  			select {
   362  			case <-syncRefresh:
   363  				require.Fail(t, "no sync refresh expected")
   364  			default:
   365  			}
   366  		}
   367  	}
   368  
   369  	tc.ChatG.AttachmentURLSrv = NewAttachmentHTTPSrv(tc.Context(),
   370  		manager.NewSrv(tc.Context().ExternalG()),
   371  		fetcher, func() chat1.RemoteInterface { return ri })
   372  	tc1.ChatG.AttachmentURLSrv = NewAttachmentHTTPSrv(tc1.Context(),
   373  		manager.NewSrv(tc1.Context().ExternalG()),
   374  		fetcher, func() chat1.RemoteInterface { return ri1 })
   375  	uploader := attachments.NewUploader(tc.Context(), store, mockSigningRemote{},
   376  		func() chat1.RemoteInterface { return ri }, 1)
   377  	tc.ChatG.AttachmentUploader = uploader
   378  	filename := "./testdata/party_parrot.gif"
   379  	t.Logf("uid1: %s", uid1)
   380  
   381  	aloneConv := mustCreateConversationForTest(t, ctc, users[0], chat1.TopicType_CHAT,
   382  		chat1.ConversationMembersType_TEAM)
   383  	sharedConv := mustCreateConversationForTest(t, ctc, users[0], chat1.TopicType_CHAT,
   384  		chat1.ConversationMembersType_TEAM, users[1], users[3])
   385  	sharedConv2 := mustCreateConversationForTest(t, ctc, users[0], chat1.TopicType_CHAT,
   386  		chat1.ConversationMembersType_TEAM, users[2], users[3])
   387  
   388  	t.Logf("basic")
   389  	_, err := tc.Context().EmojiSource.Add(ctx, uid, aloneConv.Id, "party_parrot", filename, false)
   390  	require.NoError(t, err)
   391  	_, err = tc.Context().EmojiSource.Add(ctx, uid, aloneConv.Id, "success-kid", filename, false)
   392  	require.NoError(t, err)
   393  	_, err = tc.Context().EmojiSource.Add(ctx, uid, sharedConv2.Id, "mike", filename, false)
   394  	require.NoError(t, err)
   395  	_, err = tc.Context().EmojiSource.Add(ctx, uid, sharedConv2.Id, "rock", filename, false)
   396  	require.NoError(t, err)
   397  
   398  	msgID := mustPostLocalForTest(t, ctc, users[0], sharedConv, chat1.NewMessageBodyWithText(chat1.MessageText{
   399  		Body: ":party_parrot:",
   400  	}))
   401  	checkEmoji(ctx1, t, tc1, uid1, sharedConv, msgID, "party_parrot")
   402  	expectCreated(true)
   403  	expectRefresh(true)
   404  	msgID = mustPostLocalForTest(t, ctc, users[0], sharedConv, chat1.NewMessageBodyWithText(chat1.MessageText{
   405  		Body: ":success-kid:",
   406  	}))
   407  	checkEmoji(ctx1, t, tc1, uid1, sharedConv, msgID, "success-kid")
   408  	expectCreated(false)
   409  	expectRefresh(true)
   410  	msgID = mustPostLocalForTest(t, ctc, users[0], sharedConv, chat1.NewMessageBodyWithText(chat1.MessageText{
   411  		Body: ":party_parrot:",
   412  	}))
   413  	checkEmoji(ctx1, t, tc1, uid1, sharedConv, msgID, "party_parrot")
   414  	expectCreated(false)
   415  	expectRefresh(false)
   416  	t.Logf("post from different source")
   417  	msgID = mustPostLocalForTest(t, ctc, users[0], sharedConv, chat1.NewMessageBodyWithText(chat1.MessageText{
   418  		Body: ":mike:",
   419  	}))
   420  	checkEmoji(ctx1, t, tc1, uid1, sharedConv, msgID, "mike")
   421  	expectCreated(true)
   422  	expectRefresh(true)
   423  	msgID = mustPostLocalForTest(t, ctc, users[0], sharedConv, chat1.NewMessageBodyWithText(chat1.MessageText{
   424  		Body: ":mike:",
   425  	}))
   426  	checkEmoji(ctx1, t, tc1, uid1, sharedConv, msgID, "mike")
   427  	expectCreated(false)
   428  	expectRefresh(false)
   429  	t.Logf("different user tries posting after convs are created")
   430  	msgID = mustPostLocalForTest(t, ctc, users[3], sharedConv, chat1.NewMessageBodyWithText(chat1.MessageText{
   431  		Body: ":mike:",
   432  	}))
   433  	checkEmoji(ctx1, t, tc1, uid1, sharedConv, msgID, "mike")
   434  	expectCreated(false)
   435  	expectRefresh(false)
   436  
   437  	t.Logf("collision")
   438  	_, err = tc.Context().EmojiSource.Add(ctx, uid, sharedConv2.Id, "party_parrot", filename, false)
   439  	require.NoError(t, err)
   440  	msgID = mustPostLocalForTest(t, ctc, users[0], sharedConv, chat1.NewMessageBodyWithText(chat1.MessageText{
   441  		Body: ":party_parrot#2:",
   442  	}))
   443  	checkEmoji(ctx1, t, tc1, uid1, sharedConv, msgID, "party_parrot#2")
   444  
   445  	// error on edit
   446  	_, err = tc.Context().EmojiSource.Add(ctx, uid, aloneConv.Id, "party_parrot", filename, false)
   447  	require.Error(t, err)
   448  	_, err = tc.Context().EmojiSource.Add(ctx, uid, aloneConv.Id, "party_parrot", filename, true)
   449  	require.NoError(t, err)
   450  
   451  	t.Logf("stock collision")
   452  	msgID = mustPostLocalForTest(t, ctc, users[0], sharedConv, chat1.NewMessageBodyWithText(chat1.MessageText{
   453  		Body: ":rock#2:",
   454  	}))
   455  	checkEmoji(ctx1, t, tc1, uid1, sharedConv, msgID, "rock#2")
   456  	expectCreated(false)
   457  	expectRefresh(true)
   458  }
   459  
   460  type emojiTestCase struct {
   461  	body     string
   462  	expected []emojiMatch
   463  }
   464  
   465  func TestEmojiSourceParse(t *testing.T) {
   466  	es := &DevConvEmojiSource{}
   467  	ctx := context.TODO()
   468  
   469  	testCases := []emojiTestCase{
   470  		{
   471  			body: "x :miked:",
   472  			expected: []emojiMatch{
   473  				{
   474  					name:     "miked",
   475  					position: []int{2, 9},
   476  				},
   477  			},
   478  		},
   479  		{
   480  			body: ":333mm__--M:",
   481  			expected: []emojiMatch{
   482  				{
   483  					name:     "333mm__--M",
   484  					position: []int{0, 12},
   485  				},
   486  			},
   487  		},
   488  		{
   489  			body: ":mike: :lisa:",
   490  			expected: []emojiMatch{
   491  				{
   492  					name:     "mike",
   493  					position: []int{0, 6},
   494  				},
   495  				{
   496  					name:     "lisa",
   497  					position: []int{7, 13},
   498  				},
   499  			},
   500  		},
   501  		{
   502  			body: ":mike::lisa:",
   503  			expected: []emojiMatch{
   504  				{
   505  					name:     "mike",
   506  					position: []int{0, 6},
   507  				},
   508  				{
   509  					name:     "lisa",
   510  					position: []int{6, 12},
   511  				},
   512  			},
   513  		},
   514  		{
   515  			body: "::",
   516  		},
   517  	}
   518  	for _, tc := range testCases {
   519  		res := es.parse(ctx, tc.body)
   520  		require.Equal(t, tc.expected, res)
   521  	}
   522  }
   523  
   524  func TestEmojiSourceIsStock(t *testing.T) {
   525  	es := &DevConvEmojiSource{}
   526  	require.True(t, es.IsStockEmoji("+1"))
   527  	require.True(t, es.IsStockEmoji(":+1:"))
   528  	require.True(t, es.IsStockEmoji(":+1::skin-tone-5:"))
   529  	require.False(t, es.IsStockEmoji("foo"))
   530  }