1package unfurl
2
3import (
4	"bytes"
5	"context"
6	"fmt"
7	"io"
8	"io/ioutil"
9	"net"
10	"net/http"
11	"path/filepath"
12	"strings"
13	"testing"
14	"time"
15
16	"github.com/keybase/client/go/chat/globals"
17	"github.com/keybase/client/go/chat/types"
18
19	"github.com/keybase/client/go/chat/maps"
20	"github.com/keybase/client/go/libkb"
21	"github.com/keybase/client/go/protocol/chat1"
22	"github.com/keybase/client/go/protocol/gregor1"
23	"github.com/keybase/clockwork"
24	"github.com/stretchr/testify/require"
25)
26
27type dummyHTTPSrv struct {
28	t                         *testing.T
29	srv                       *http.Server
30	shouldServeAppleTouchIcon bool
31	handler                   func(w http.ResponseWriter, r *http.Request)
32}
33
34func newDummyHTTPSrv(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *dummyHTTPSrv {
35	return &dummyHTTPSrv{
36		t:       t,
37		handler: handler,
38	}
39}
40
41func (d *dummyHTTPSrv) Start() string {
42	localhost := "127.0.0.1"
43	listener, err := net.Listen("tcp", fmt.Sprintf("%s:0", localhost))
44	require.NoError(d.t, err)
45	port := listener.Addr().(*net.TCPAddr).Port
46	mux := http.NewServeMux()
47	mux.HandleFunc("/", d.handler)
48	mux.HandleFunc("/apple-touch-icon.png", d.serveAppleTouchIcon)
49	d.srv = &http.Server{
50		Addr:    fmt.Sprintf("%s:%d", localhost, port),
51		Handler: mux,
52	}
53	go func() { _ = d.srv.Serve(listener) }()
54	return d.srv.Addr
55}
56
57func (d *dummyHTTPSrv) Stop() {
58	require.NoError(d.t, d.srv.Close())
59}
60
61func (d *dummyHTTPSrv) serveAppleTouchIcon(w http.ResponseWriter, r *http.Request) {
62	if d.shouldServeAppleTouchIcon {
63		w.WriteHeader(200)
64		dat, _ := ioutil.ReadFile(filepath.Join("testcases", "github.png"))
65		_, _ = io.Copy(w, bytes.NewBuffer(dat))
66		return
67	}
68	w.WriteHeader(404)
69}
70
71func strPtr(s string) *string {
72	return &s
73}
74
75func intPtr(i int) *int {
76	return &i
77}
78
79func createTestCaseHTTPSrv(t *testing.T) *dummyHTTPSrv {
80	return newDummyHTTPSrv(t, func(w http.ResponseWriter, r *http.Request) {
81		w.WriteHeader(200)
82		name := r.URL.Query().Get("name")
83		contentType := r.URL.Query().Get("content_type")
84		if len(contentType) > 0 {
85			w.Header().Set("Content-Type", contentType)
86		}
87		dat, err := ioutil.ReadFile(filepath.Join("testcases", name))
88		require.NoError(t, err)
89		_, err = io.Copy(w, bytes.NewBuffer(dat))
90		require.NoError(t, err)
91	})
92}
93
94func TestScraper(t *testing.T) {
95	tc := libkb.SetupTest(t, "scraper", 1)
96	defer tc.Cleanup()
97	g := globals.NewContext(tc.G, &globals.ChatContext{})
98	scraper := NewScraper(g)
99
100	clock := clockwork.NewFakeClock()
101	scraper.cache.setClock(clock)
102	scraper.giphyProxy = false
103
104	srv := createTestCaseHTTPSrv(t)
105	addr := srv.Start()
106	defer srv.Stop()
107	forceGiphy := new(chat1.UnfurlType)
108	*forceGiphy = chat1.UnfurlType_GIPHY
109	testCase := func(name string, expected chat1.UnfurlRaw, success bool, contentType *string,
110		forceTyp *chat1.UnfurlType) {
111		uri := fmt.Sprintf("http://%s/?name=%s", addr, name)
112		if contentType != nil {
113			uri += fmt.Sprintf("&content_type=%s", *contentType)
114		}
115		res, err := scraper.Scrape(context.TODO(), uri, forceTyp)
116		if !success {
117			require.Error(t, err)
118			return
119		}
120		require.NoError(t, err)
121		etyp, err := expected.UnfurlType()
122		require.NoError(t, err)
123		rtyp, err := res.UnfurlType()
124		require.NoError(t, err)
125		require.Equal(t, etyp, rtyp)
126
127		t.Logf("expected:\n%v\n\nactual:\n%v", expected, res)
128		switch rtyp {
129		case chat1.UnfurlType_GENERIC:
130			e := expected.Generic()
131			r := res.Generic()
132			require.Equal(t, e.Title, r.Title)
133			require.Equal(t, e.SiteName, r.SiteName)
134			require.True(t, (e.Description == nil && r.Description == nil) || (e.Description != nil && r.Description != nil))
135			if e.Description != nil {
136				require.Equal(t, *e.Description, *r.Description)
137			}
138			require.True(t, (e.PublishTime == nil && r.PublishTime == nil) || (e.PublishTime != nil && r.PublishTime != nil))
139			if e.PublishTime != nil {
140				require.Equal(t, *e.PublishTime, *r.PublishTime)
141			}
142
143			require.True(t, (e.ImageUrl == nil && r.ImageUrl == nil) || (e.ImageUrl != nil && r.ImageUrl != nil))
144			if e.ImageUrl != nil {
145				require.Equal(t, *e.ImageUrl, *r.ImageUrl)
146			}
147
148			require.True(t, (e.FaviconUrl == nil && r.FaviconUrl == nil) || (e.FaviconUrl != nil && r.FaviconUrl != nil))
149			if e.FaviconUrl != nil {
150				require.Equal(t, *e.FaviconUrl, *r.FaviconUrl)
151			}
152
153			require.True(t, (e.Video == nil && r.Video == nil) || (e.Video != nil && r.Video != nil))
154			if e.Video != nil {
155				require.Equal(t, e.Video.Url, r.Video.Url)
156				require.Equal(t, e.Video.Height, r.Video.Height)
157				require.Equal(t, e.Video.Width, r.Video.Width)
158			}
159		case chat1.UnfurlType_GIPHY:
160			e := expected.Giphy()
161			r := res.Giphy()
162			require.Equal(t, e.ImageUrl, r.ImageUrl)
163			require.NotNil(t, r.FaviconUrl)
164			require.NotNil(t, e.FaviconUrl)
165			require.Equal(t, *e.FaviconUrl, *r.FaviconUrl)
166			require.NotNil(t, r.Video)
167			require.NotNil(t, e.Video)
168			require.Equal(t, e.Video.Url, r.Video.Url)
169			require.Equal(t, e.Video.Height, r.Video.Height)
170			require.Equal(t, e.Video.Width, r.Video.Width)
171		default:
172			require.Fail(t, "unknown unfurl typ")
173		}
174
175		// test caching
176		cachedRes, valid := scraper.cache.get(uri)
177		require.True(t, valid)
178		require.Equal(t, res, cachedRes.data.(chat1.UnfurlRaw))
179
180		clock.Advance(defaultCacheLifetime * 2)
181		cachedRes, valid = scraper.cache.get(uri)
182		require.False(t, valid)
183	}
184
185	testCase("cnn0.html", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{
186		Title:       "Kanye West seeks separation from politics",
187		Url:         "https://www.cnn.com/2018/10/30/entertainment/kanye-west-politics/index.html",
188		SiteName:    "CNN",
189		Description: strPtr("Just weeks after visiting the White House, Kanye West appears to be a little tired of politics."),
190		PublishTime: intPtr(1540941044),
191		ImageUrl:    strPtr("https://cdn.cnn.com/cnnnext/dam/assets/181011162312-11-week-in-photos-1011-super-tease.jpg"),
192		FaviconUrl:  strPtr("http://cdn.cnn.com/cnn/.e/img/3.0/global/misc/apple-touch-icon.png"),
193	}), true, nil, nil)
194	testCase("wsj0.html", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{
195		Title:       "U.S. Stocks Jump as Tough Month Sets to Wrap",
196		Url:         "https://www.wsj.com/articles/global-stocks-rally-to-end-a-tough-month-1540976261",
197		SiteName:    "WSJ",
198		Description: strPtr("A surge in technology shares following Facebook’s latest earnings lifted U.S. stocks, helping major indexes trim some of their October declines following a punishing period for global investors."),
199		PublishTime: intPtr(1541004540),
200		ImageUrl:    strPtr("https://images.wsj.net/im-33925/social"),
201		FaviconUrl:  strPtr("https://s.wsj.net/media/wsj_apple-touch-icon-180x180.png"),
202	}), true, nil, nil)
203	testCase("nytimes0.html", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{
204		Title:       "First Up if Democrats Win: Campaign and Ethics Changes, Infrastructure and Drug Prices",
205		Url:         "https://www.nytimes.com/2018/10/31/us/politics/democrats-midterm-elections.html",
206		SiteName:    "0.1", // the default for these tests (from the localhost domain)
207		Description: strPtr("House Democratic leaders, for the first time, laid out an ambitious opening salvo of bills for a majority, including an overhaul of campaign and ethics laws."),
208		PublishTime: intPtr(1540990881),
209		ImageUrl:    strPtr("https://static01.nyt.com/images/2018/10/31/us/politics/31dc-dems/31dc-dems-facebookJumbo.jpg"),
210		FaviconUrl:  strPtr("http://127.0.0.1/vi-assets/static-assets/apple-touch-icon-319373aaf4524d94d38aa599c56b8655.png"),
211	}), true, nil, nil)
212	srv.shouldServeAppleTouchIcon = true
213	testCase("github0.html", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{
214		Title:       "keybase/client",
215		Url:         "https://github.com/keybase/client",
216		SiteName:    "GitHub",
217		Description: strPtr("Keybase Go Library, Client, Service, OS X, iOS, Android, Electron - keybase/client"),
218		ImageUrl:    strPtr("https://avatars1.githubusercontent.com/u/5400834?s=400&v=4"),
219		FaviconUrl:  strPtr(fmt.Sprintf("http://%s/apple-touch-icon.png", addr)),
220	}), true, nil, nil)
221	srv.shouldServeAppleTouchIcon = false
222	testCase("youtube0.html", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{
223		Title:       "Mario Kart Wii: The History of the Ultra Shortcut",
224		Url:         "https://www.youtube.com/watch?v=mmJ_LT8bUj0",
225		SiteName:    "YouTube",
226		Description: strPtr("https://www.twitch.tv/summoningsalt https://twitter.com/summoningsalt Music List- https://docs.google.com/document/d/1p2qV31ZhtNuP7AAXtRjGNZr2QwMSolzuz2wX6wu..."),
227		ImageUrl:    strPtr("https://i.ytimg.com/vi/mmJ_LT8bUj0/hqdefault.jpg"),
228		FaviconUrl:  strPtr("https://s.ytimg.com/yts/img/favicon-vfl8qSV2F.ico"),
229	}), true, nil, nil)
230	testCase("youtube1.html", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{
231		Title:       "Pumped to Be Here: Brazil's Game Fans",
232		Url:         "https://www.youtube.com/watch?v=mmJ_LT8bUj0",
233		SiteName:    "YouTube",
234		Description: strPtr("Brazil's games, consoles, and markets may seem strange, but there's plenty that's familiar, too. SUPPORT US ON PATREON! https://patreon.com/clothmap Patrons ..."),
235		ImageUrl:    strPtr("https://i.ytimg.com/vi/6IIQFBb4exU/maxresdefault.jpg"),
236		FaviconUrl:  strPtr("https://s.ytimg.com/yts/img/favicon-vfl8qSV2F.ico"),
237	}), true, nil, nil)
238	testCase("twitter0.html", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{
239		Title:       "Ars Technica on Twitter",
240		Url:         "https://twitter.com/arstechnica/status/1057679097869094917",
241		SiteName:    "Twitter",
242		Description: strPtr("“Nintendo recommits to “keep the business going” for 3DS https://t.co/wTIJxmGTJH by @KyleOrl”"),
243		ImageUrl:    strPtr("https://pbs.twimg.com/profile_images/2215576731/ars-logo_400x400.png"),
244		FaviconUrl:  strPtr("https://abs.twimg.com/icons/apple-touch-icon-192x192.png"),
245	}), true, nil, nil)
246	testCase("pinterest0.html", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{
247		Title:       "Halloween",
248		Url:         "https://www.pinterest.com/pinterest/halloween/",
249		SiteName:    "Pinterest",
250		Description: strPtr("Dracula dentures, kitten costumes, no-carve pumpkins—find your next killer idea on Pinterest."),
251		ImageUrl:    strPtr("https://i.pinimg.com/custom_covers/200x150/424605139807203572_1414340303.jpg"),
252		FaviconUrl:  strPtr("https://s.pinimg.com/webapp/style/images/logo_trans_144x144-642179a1.png"),
253	}), true, nil, nil)
254	testCase("wikipedia0.html", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{
255		Title:       "Merkle tree - Wikipedia",
256		SiteName:    "0.1",
257		Description: nil,
258		ImageUrl:    strPtr("https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Hash_Tree.svg/1200px-Hash_Tree.svg.png"),
259		FaviconUrl:  strPtr("http://127.0.0.1/static/apple-touch/wikipedia.png"),
260	}), true, nil, nil)
261	testCase("reddit0.html", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{
262		Title:       "r/Stellar",
263		Url:         "https://www.reddit.com/r/Stellar/",
264		SiteName:    "reddit",
265		Description: strPtr("r/Stellar: Stellar is a decentralized protocol that enables you to send money to anyone in the world, for fractions of a penny, instantly, and in any currency.  \n\n/r/Stellar is for news, announcements and discussion related to Stellar.\n\nPlease focus on community-oriented content, such as news and discussions, instead of individual-oriented content, such as questions and help. Follow the [Stellar Community Guidelines](https://www.stellar.org/community-guidelines/) ."),
266		ImageUrl:    strPtr("https://b.thumbs.redditmedia.com/D857u25iiE2ORpt8yVx7fCuiMlLVP-b5fwSUjaw4lVU.png"),
267		FaviconUrl:  strPtr("https://www.redditstatic.com/desktop2x/img/favicon/apple-icon-180x180.png"),
268	}), true, nil, nil)
269	testCase("etsy0.html", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{
270		Title:       "The Beatles - Minimalist Poster - Sgt Pepper",
271		Url:         "https://www.etsy.com/listing/602032869/the-beatles-minimalist-poster-sgt-pepper?utm_source=OpenGraph&utm_medium=PageTools&utm_campaign=Share",
272		SiteName:    "Etsy",
273		Description: strPtr("The Beatles Sgt Peppers Lonely Hearts Club Ban  Created using mixed media  Fits a 10 x 8 inch frame aperture - photograph shows item framed in a 12 x 10 inch frame  Choose from: high lustre paper - 210g which produces very vibrant colours; textured watercolour paper - 190g - which looks"),
274		ImageUrl:    strPtr("https://i.etsystatic.com/12686588/r/il/c3b4bc/1458062296/il_570xN.1458062296_rary.jpg"),
275		FaviconUrl:  strPtr("http://127.0.0.1/images/favicon.ico"),
276	}), true, nil, nil)
277	testCase("giphy0.html", chat1.NewUnfurlRawWithGiphy(chat1.UnfurlGiphyRaw{
278		ImageUrl:   strPtr("https://media.giphy.com/media/5C3Zrs5xUg5fHV4Kcf/giphy-downsized-large.gif"),
279		FaviconUrl: strPtr("https://giphy.com/static/img/icons/apple-touch-icon-180px.png"),
280		Video: &chat1.UnfurlVideo{
281			Url:    "https://media.giphy.com/media/5C3Zrs5xUg5fHV4Kcf/giphy.mp4",
282			Height: 480,
283			Width:  480,
284		},
285	}), true, nil, forceGiphy)
286	testCase("imgur0.html", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{
287		Title:       "Amazing Just Cause 4 Easter egg",
288		Url:         "https://i.imgur.com/lXDyzHY.gifv",
289		SiteName:    "Imgur",
290		Description: strPtr("2433301 views and 2489 votes on Imgur"),
291		ImageUrl:    strPtr("https://i.imgur.com/lXDyzHY.jpg?play"),
292		FaviconUrl:  strPtr(fmt.Sprintf("http://%s/favicon.ico", addr)),
293		Video: &chat1.UnfurlVideo{
294			Url:    "https://i.imgur.com/lXDyzHY.mp4",
295			Height: 408,
296			Width:  728,
297		},
298	}), true, nil, nil)
299	srv.shouldServeAppleTouchIcon = false
300	testCase("nytogimage.jpg", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{
301		SiteName:   "0.1",
302		FaviconUrl: strPtr(fmt.Sprintf("http://%s/favicon.ico", addr)),
303		ImageUrl:   strPtr(fmt.Sprintf("http://%s/?name=nytogimage.jpg&content_type=image/jpeg", addr)),
304	}), true, strPtr("image/jpeg"), nil)
305	srv.shouldServeAppleTouchIcon = true
306	testCase("slim.html", chat1.NewUnfurlRawWithGeneric(chat1.UnfurlGenericRaw{}), false, nil, nil)
307
308}
309
310func TestGiphySearchScrape(t *testing.T) {
311	tc := libkb.SetupTest(t, "giphyScraper", 1)
312	defer tc.Cleanup()
313	g := globals.NewContext(tc.G, &globals.ChatContext{})
314	scraper := NewScraper(g)
315
316	clock := clockwork.NewFakeClock()
317	scraper.cache.setClock(clock)
318	scraper.giphyProxy = false
319
320	url := "https://media0.giphy.com/media/iJDLBX5GY8niCpZYkR/giphy.mp4#height=360&width=640&isvideo=true"
321	res, err := scraper.Scrape(context.TODO(), url, nil)
322	require.NoError(t, err)
323	typ, err := res.UnfurlType()
324	require.NoError(t, err)
325	require.Equal(t, chat1.UnfurlType_GIPHY, typ)
326	require.Nil(t, res.Giphy().ImageUrl)
327	require.NotNil(t, res.Giphy().Video)
328	require.Equal(t, res.Giphy().Video.Url, url)
329	require.Equal(t, 360, res.Giphy().Video.Height)
330	require.Equal(t, 640, res.Giphy().Video.Width)
331
332	url = "https://media0.giphy.com/media/iJDLBX5GY8niCpZYkR/giphy.mp4#height=360&width=640&isvideo=false"
333	res, err = scraper.Scrape(context.TODO(), url, nil)
334	require.NoError(t, err)
335	typ, err = res.UnfurlType()
336	require.NoError(t, err)
337	require.Equal(t, chat1.UnfurlType_GIPHY, typ)
338	require.NotNil(t, res.Giphy().ImageUrl)
339	require.Nil(t, res.Giphy().Video)
340	require.Equal(t, *res.Giphy().ImageUrl, url)
341}
342
343func TestMapScraper(t *testing.T) {
344	tc := libkb.SetupTest(t, "mapScraper", 1)
345	defer tc.Cleanup()
346	g := globals.NewContext(tc.G, &globals.ChatContext{
347		ExternalAPIKeySource: types.DummyExternalAPIKeySource{},
348	})
349	scraper := NewScraper(g)
350	lat := 40.800099
351	lon := -73.969341
352	acc := 65.00
353	url := fmt.Sprintf("https://%s/?lat=%f&lon=%f&acc=%f&done=true", types.MapsDomain, lat, lon, acc)
354	unfurl, err := scraper.Scrape(context.TODO(), url, nil)
355	require.NoError(t, err)
356	typ, err := unfurl.UnfurlType()
357	require.NoError(t, err)
358	require.Equal(t, chat1.UnfurlType_MAPS, typ)
359	require.True(t, strings.Contains(unfurl.Maps().Url, fmt.Sprintf("%f", lat)))
360	require.True(t, strings.Contains(unfurl.Maps().Url, fmt.Sprintf("%f", lon)))
361	require.NotNil(t, unfurl.Maps().ImageUrl)
362	require.True(t, strings.Contains(unfurl.Maps().ImageUrl, maps.MapsProxy))
363	require.True(t, strings.Contains(unfurl.Maps().ImageUrl, fmt.Sprintf("%f", lat)))
364	require.True(t, strings.Contains(unfurl.Maps().ImageUrl, fmt.Sprintf("%f", lon)))
365}
366
367type testingLiveLocationTracker struct {
368	coords []chat1.Coordinate
369}
370
371func (t *testingLiveLocationTracker) Start(ctx context.Context, uid gregor1.UID) {}
372func (t *testingLiveLocationTracker) Stop(ctx context.Context) chan struct{} {
373	ch := make(chan struct{})
374	close(ch)
375	return ch
376}
377
378func (t *testingLiveLocationTracker) StartTracking(ctx context.Context, convID chat1.ConversationID,
379	msgID chat1.MessageID, endTime time.Time) {
380}
381
382func (t *testingLiveLocationTracker) GetCurrentPosition(ctx context.Context, convID chat1.ConversationID,
383	msgID chat1.MessageID) {
384}
385
386func (t *testingLiveLocationTracker) LocationUpdate(ctx context.Context, coord chat1.Coordinate) {
387	t.coords = append(t.coords, coord)
388}
389
390func (t *testingLiveLocationTracker) GetCoordinates(ctx context.Context, key types.LiveLocationKey) []chat1.Coordinate {
391	return t.coords
392}
393
394func (t *testingLiveLocationTracker) GetEndTime(ctx context.Context, key types.LiveLocationKey) *time.Time {
395	return nil
396}
397
398func (t *testingLiveLocationTracker) ActivelyTracking(ctx context.Context) bool {
399	return false
400}
401
402func (t *testingLiveLocationTracker) StopAllTracking(ctx context.Context) {}
403
404func TestLiveMapScraper(t *testing.T) {
405	tc := libkb.SetupTest(t, "liveMapScraper", 1)
406	defer tc.Cleanup()
407	liveLocation := &testingLiveLocationTracker{}
408	g := globals.NewContext(tc.G, &globals.ChatContext{
409		ExternalAPIKeySource: types.DummyExternalAPIKeySource{},
410		LiveLocationTracker:  liveLocation,
411	})
412	scraper := NewScraper(g)
413	first := chat1.Coordinate{
414		Lat:      40.800099,
415		Lon:      -73.969341,
416		Accuracy: 65.00,
417	}
418	watchID := chat1.LocationWatchID(20)
419	liveLocation.LocationUpdate(context.TODO(), chat1.Coordinate{
420		Lat:      40.756325,
421		Lon:      -73.992533,
422		Accuracy: 65,
423	})
424	liveLocation.LocationUpdate(context.TODO(), chat1.Coordinate{
425		Lat:      40.704454,
426		Lon:      -74.010893,
427		Accuracy: 65,
428	})
429	url := fmt.Sprintf("https://%s/?lat=%f&lon=%f&acc=%f&watchID=%d&done=false&livekey=mike",
430		types.MapsDomain, first.Lat, first.Lon, first.Accuracy, watchID)
431	unfurl, err := scraper.Scrape(context.TODO(), url, nil)
432	require.NoError(t, err)
433	typ, err := unfurl.UnfurlType()
434	require.NoError(t, err)
435	require.Equal(t, chat1.UnfurlType_MAPS, typ)
436	require.NotZero(t, len(unfurl.Maps().ImageUrl))
437	require.Equal(t, "Live Location Share", unfurl.Maps().SiteName)
438
439	url = fmt.Sprintf("https://%s/?lat=%f&lon=%f&acc=%f&watchID=%d&done=true&livekey=mike", types.MapsDomain,
440		first.Lat, first.Lon, first.Accuracy, watchID)
441	unfurl, err = scraper.Scrape(context.TODO(), url, nil)
442	require.NoError(t, err)
443	typ, err = unfurl.UnfurlType()
444	require.NoError(t, err)
445	require.Equal(t, chat1.UnfurlType_MAPS, typ)
446	require.Equal(t, "Live Location Share", unfurl.Maps().SiteName)
447	require.Equal(t, "Location share ended", unfurl.Maps().Title)
448}
449