expressions_handler_test.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  1. package webapi
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "log/slog"
  8. "net/http"
  9. "net/http/httptest"
  10. "testing"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/mock"
  13. "github.com/mk6i/open-oscar-server/state"
  14. "github.com/mk6i/open-oscar-server/wire"
  15. )
  16. // blankIconGIF stands in for the blank placeholder the real BART service returns
  17. // for the clear-icon hash; distinct from iconGIF so tests can tell them apart.
  18. var blankIconGIF = []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0xff}
  19. // newExpressionsHandler builds a handler whose target user has the given icon,
  20. // or no icon when id is nil. Its BART mock mirrors the real service: the
  21. // clear-icon hash resolves to the blank placeholder, any other hash to iconGIF.
  22. func newExpressionsHandler(t *testing.T, id *wire.BARTID) *ExpressionsHandler {
  23. iconRetriever := newMockBuddyIconRetriever(t)
  24. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).Return(id, nil).Maybe()
  25. bartService := newMockBARTService(t)
  26. bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything,
  27. mock.MatchedBy(func(q wire.SNAC_0x10_0x04_BARTDownloadQuery) bool { return q.HasClearIconHash() })).
  28. Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: blankIconGIF}}, nil).Maybe()
  29. bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything, mock.Anything).
  30. Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: iconGIF}}, nil).Maybe()
  31. return NewExpressionsHandler(BuddyIconSource{
  32. IconRetriever: iconRetriever,
  33. BARTService: bartService,
  34. Logger: slog.Default(),
  35. }, nil, nil, slog.Default())
  36. }
  37. func TestExpressionsHandler_Get_ServesIconBytes(t *testing.T) {
  38. h := newExpressionsHandler(t, bartID([]byte{0xde, 0xad}))
  39. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead", nil)
  40. w := httptest.NewRecorder()
  41. h.Get(w, r)
  42. assert.Equal(t, http.StatusOK, w.Code)
  43. assert.Equal(t, iconGIF, w.Body.Bytes())
  44. assert.Equal(t, "image/gif", w.Header().Get("Content-Type"))
  45. // The URL pins the icon hash, so it always resolves to the same image.
  46. assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control"))
  47. }
  48. func TestExpressionsHandler_Get_IconWithoutHashIsNotCached(t *testing.T) {
  49. // Without a hash the URL keeps resolving to whatever the current icon is, so
  50. // caching it would pin a stale image.
  51. h := newExpressionsHandler(t, bartID([]byte{0xde, 0xad}))
  52. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon", nil)
  53. w := httptest.NewRecorder()
  54. h.Get(w, r)
  55. assert.Equal(t, http.StatusOK, w.Code)
  56. assert.Equal(t, "no-cache", w.Header().Get("Cache-Control"))
  57. }
  58. func TestExpressionsHandler_Get_MissingIconServesPlaceholder(t *testing.T) {
  59. // A user with no icon still serves the blank placeholder for the hash-less
  60. // URL, so the client's <img> renders something and a cleared icon stops
  61. // showing the previous one rather than 404ing.
  62. h := newExpressionsHandler(t, nil)
  63. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon", nil)
  64. w := httptest.NewRecorder()
  65. h.Get(w, r)
  66. assert.Equal(t, http.StatusOK, w.Code)
  67. assert.Equal(t, blankIconGIF, w.Body.Bytes())
  68. assert.Equal(t, "no-cache", w.Header().Get("Cache-Control"))
  69. }
  70. func TestExpressionsHandler_Get_BartIdServesRequestedHash(t *testing.T) {
  71. // Even though the user's *current* icon is hash B, a URL pinned to hash A must
  72. // serve A's bytes and cache immutably — otherwise a cached URL could later
  73. // resolve to a different image.
  74. iconRetriever := newMockBuddyIconRetriever(t)
  75. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).
  76. Return(bartID([]byte{0xbb}), nil).Maybe()
  77. bytesA := []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0xa1}
  78. bartService := newMockBARTService(t)
  79. bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything,
  80. mock.MatchedBy(func(q wire.SNAC_0x10_0x04_BARTDownloadQuery) bool {
  81. return bytes.Equal(q.Hash, []byte{0xaa})
  82. })).
  83. Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: bytesA}}, nil).Once()
  84. h := NewExpressionsHandler(BuddyIconSource{
  85. IconRetriever: iconRetriever,
  86. BARTService: bartService,
  87. Logger: slog.Default(),
  88. }, nil, nil, slog.Default())
  89. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon&bartId=aa", nil)
  90. w := httptest.NewRecorder()
  91. h.Get(w, r)
  92. assert.Equal(t, http.StatusOK, w.Code)
  93. assert.Equal(t, bytesA, w.Body.Bytes())
  94. assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control"))
  95. }
  96. func TestExpressionsHandler_Get_UnknownBartIdNotFound(t *testing.T) {
  97. iconRetriever := newMockBuddyIconRetriever(t)
  98. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).
  99. Return(bartID([]byte{0xbb}), nil).Maybe()
  100. // An empty reply means the hash is not stored.
  101. bartService := newMockBARTService(t)
  102. bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything, mock.Anything).
  103. Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{}}, nil).Once()
  104. h := NewExpressionsHandler(BuddyIconSource{
  105. IconRetriever: iconRetriever,
  106. BARTService: bartService,
  107. Logger: slog.Default(),
  108. }, nil, nil, slog.Default())
  109. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon&bartId=abcdef", nil)
  110. w := httptest.NewRecorder()
  111. h.Get(w, r)
  112. assert.Equal(t, http.StatusNotFound, w.Code)
  113. }
  114. func TestExpressionsHandler_Get_ListsBigBuddyIcon(t *testing.T) {
  115. // This is the shape the client scans for its large icon rendering.
  116. h := newExpressionsHandler(t, bartID([]byte{0xde, 0xad}))
  117. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly", nil)
  118. r.Host = "api.example.com"
  119. w := httptest.NewRecorder()
  120. h.Get(w, r)
  121. assert.Equal(t, http.StatusOK, w.Code)
  122. var got struct {
  123. Response struct {
  124. StatusCode int `json:"statusCode"`
  125. Data struct {
  126. Expressions []struct {
  127. Type string `json:"type"`
  128. URL string `json:"url"`
  129. } `json:"expressions"`
  130. } `json:"data"`
  131. } `json:"response"`
  132. }
  133. assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
  134. assert.Equal(t, 200, got.Response.StatusCode)
  135. assert.Len(t, got.Response.Data.Expressions, 1)
  136. assert.Equal(t, "bigBuddyIcon", got.Response.Data.Expressions[0].Type)
  137. assert.Equal(t,
  138. "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead",
  139. got.Response.Data.Expressions[0].URL)
  140. }
  141. func TestExpressionsHandler_Get_ListsNothingWithoutIcon(t *testing.T) {
  142. h := newExpressionsHandler(t, nil)
  143. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly", nil)
  144. w := httptest.NewRecorder()
  145. h.Get(w, r)
  146. assert.Equal(t, http.StatusOK, w.Code)
  147. assert.Contains(t, w.Body.String(), `"expressions":[]`)
  148. }
  149. func TestExpressionsHandler_Get_Redirect(t *testing.T) {
  150. h := newExpressionsHandler(t, bartID([]byte{0xde, 0xad}))
  151. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&f=redirect", nil)
  152. r.Host = "api.example.com"
  153. w := httptest.NewRecorder()
  154. h.Get(w, r)
  155. assert.Equal(t, http.StatusFound, w.Code)
  156. assert.Equal(t,
  157. "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead",
  158. w.Header().Get("Location"))
  159. }
  160. func TestExpressionsHandler_Get_RedirectWithoutIcon(t *testing.T) {
  161. h := newExpressionsHandler(t, nil)
  162. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&f=redirect", nil)
  163. w := httptest.NewRecorder()
  164. h.Get(w, r)
  165. assert.Equal(t, http.StatusNotFound, w.Code)
  166. }
  167. func TestExpressionsHandler_Get_MissingTarget(t *testing.T) {
  168. h := newExpressionsHandler(t, nil)
  169. r := httptest.NewRequest(http.MethodGet, "/expressions/get", nil)
  170. w := httptest.NewRecorder()
  171. h.Get(w, r)
  172. assert.Equal(t, http.StatusBadRequest, w.Code)
  173. }
  174. func newUploadSession() *Session {
  175. return &Session{
  176. AimSID: "sid",
  177. ScreenName: state.DisplayScreenName("testuser"),
  178. OSCARSession: state.NewSession().AddInstance(),
  179. }
  180. }
  181. // The Android client posts the raw JPEG with a Content-Type of
  182. // application/x-www-form-urlencoded. The handler must read the body as bytes and
  183. // must not let anything parse it as a form.
  184. func TestExpressionsHandler_Upload_StoresAndPublishesIcon(t *testing.T) {
  185. image := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
  186. hash := []byte{0xde, 0xad, 0xbe, 0xef}
  187. uploader := newMockBARTService(t)
  188. uploader.EXPECT().UpsertItem(mock.Anything, mock.Anything,
  189. wire.SNACFrame{FoodGroup: wire.BART, SubGroup: wire.BARTUploadQuery},
  190. wire.SNAC_0x10_0x02_BARTUploadQuery{Type: wire.BARTTypesBuddyIcon, Data: image},
  191. ).Return(wire.SNACMessage{
  192. Body: wire.SNAC_0x10_0x03_BARTUploadReply{
  193. Code: wire.BARTReplyCodesSuccess,
  194. ID: wire.BARTID{
  195. Type: wire.BARTTypesBuddyIcon,
  196. BARTInfo: wire.BARTInfo{Flags: wire.BARTFlagsCustom, Hash: hash},
  197. },
  198. },
  199. }, nil).Once()
  200. fs := newMockFeedbagService(t)
  201. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  202. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{
  203. Items: []wire.FeedbagItem{
  204. // The root group, which lives at GroupID 0 / ItemID 0.
  205. {ClassID: wire.FeedbagClassIdGroup},
  206. },
  207. }}, nil).Once()
  208. var (
  209. upserted []wire.FeedbagItem
  210. upsertFrame wire.SNACFrame
  211. )
  212. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  213. Run(func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) {
  214. upsertFrame = inFrame
  215. upserted = items
  216. }).Return((*wire.SNACMessage)(nil), nil).Once()
  217. h := NewExpressionsHandler(BuddyIconSource{Logger: slog.Default()}, uploader, fs, slog.Default())
  218. req := httptest.NewRequest(http.MethodPost,
  219. "/expressions/upload?f=json&aimsid=sid&type=buddyIcon", bytes.NewReader(image))
  220. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  221. rr := httptest.NewRecorder()
  222. h.Upload(rr, req, newUploadSession())
  223. assert.Equal(t, http.StatusOK, rr.Code)
  224. var got struct {
  225. Response struct {
  226. StatusCode int `json:"statusCode"`
  227. Data struct {
  228. ID string `json:"id"`
  229. } `json:"data"`
  230. } `json:"response"`
  231. }
  232. assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
  233. assert.Equal(t, 200, got.Response.StatusCode)
  234. // type as 4 hex digits, then the content hash.
  235. assert.Equal(t, "0001deadbeef", got.Response.Data.ID)
  236. // The icon must be published to the feedbag, or expressions/get never sees it.
  237. assert.Len(t, upserted, 1)
  238. assert.Equal(t, wire.FeedbagClassIdBart, upserted[0].ClassID)
  239. assert.Equal(t, "1", upserted[0].Name)
  240. // A first icon must not land on the root group's (0, 0) row.
  241. assert.NotZero(t, upserted[0].ItemID)
  242. assert.Equal(t, uint16(wire.FeedbagInsertItem), upsertFrame.SubGroup)
  243. b, ok := upserted[0].Bytes(wire.FeedbagAttributesBartInfo)
  244. assert.True(t, ok)
  245. info := wire.BARTInfo{}
  246. assert.NoError(t, wire.UnmarshalBE(&info, bytes.NewBuffer(b)))
  247. assert.Equal(t, hash, info.Hash)
  248. }
  249. // An existing icon is replaced in place rather than added alongside.
  250. func TestExpressionsHandler_Upload_ReusesExistingFeedbagItem(t *testing.T) {
  251. uploader := newMockBARTService(t)
  252. uploader.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  253. Return(wire.SNACMessage{
  254. Body: wire.SNAC_0x10_0x03_BARTUploadReply{
  255. Code: wire.BARTReplyCodesSuccess,
  256. ID: wire.BARTID{Type: wire.BARTTypesBuddyIcon, BARTInfo: wire.BARTInfo{Hash: []byte{0x01}}},
  257. },
  258. }, nil).Once()
  259. fs := newMockFeedbagService(t)
  260. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  261. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{
  262. Items: []wire.FeedbagItem{
  263. {GroupID: 7, ItemID: 9, ClassID: wire.FeedbagClassIdBart, Name: "1"},
  264. },
  265. }}, nil).Once()
  266. var (
  267. upserted []wire.FeedbagItem
  268. upsertFrame wire.SNACFrame
  269. )
  270. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  271. Run(func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) {
  272. upsertFrame = inFrame
  273. upserted = items
  274. }).
  275. Return((*wire.SNACMessage)(nil), nil).Once()
  276. h := NewExpressionsHandler(BuddyIconSource{Logger: slog.Default()}, uploader, fs, slog.Default())
  277. req := httptest.NewRequest(http.MethodPost,
  278. "/expressions/upload?type=buddyIcon", bytes.NewReader([]byte{0x01, 0x02}))
  279. rr := httptest.NewRecorder()
  280. h.Upload(rr, req, newUploadSession())
  281. assert.Equal(t, http.StatusOK, rr.Code)
  282. assert.Len(t, upserted, 1)
  283. assert.Equal(t, uint16(7), upserted[0].GroupID)
  284. assert.Equal(t, uint16(9), upserted[0].ItemID)
  285. // Other instances are relayed this frame verbatim, so replacing an icon has
  286. // to go out as an update of an item they already hold, not an insert.
  287. assert.Equal(t, uint16(wire.FeedbagUpdateItem), upsertFrame.SubGroup)
  288. }
  289. func TestExpressionsHandler_Upload_RejectsBadRequests(t *testing.T) {
  290. cases := []struct {
  291. name string
  292. url string
  293. body []byte
  294. }{
  295. {"missing type", "/expressions/upload?f=json", []byte{0x01}},
  296. {"unsupported type", "/expressions/upload?type=wallpaper", []byte{0x01}},
  297. {"empty body", "/expressions/upload?type=buddyIcon", nil},
  298. {"oversized body", "/expressions/upload?type=buddyIcon", make([]byte, bartUploadMaxBytes+1)},
  299. }
  300. for _, tc := range cases {
  301. t.Run(tc.name, func(t *testing.T) {
  302. uploader := newMockBARTService(t)
  303. fs := newMockFeedbagService(t)
  304. h := NewExpressionsHandler(BuddyIconSource{Logger: slog.Default()}, uploader, fs, slog.Default())
  305. req := httptest.NewRequest(http.MethodPost, tc.url, bytes.NewReader(tc.body))
  306. rr := httptest.NewRecorder()
  307. h.Upload(rr, req, newUploadSession())
  308. assert.Equal(t, http.StatusBadRequest, rr.Code)
  309. // Nothing may be stored or published when the request is rejected.
  310. uploader.AssertNotCalled(t, "UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
  311. fs.AssertNotCalled(t, "UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
  312. })
  313. }
  314. }
  315. // iconGIF stands in for buddy icon image bytes.
  316. var iconGIF = []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00}
  317. func bartID(hash []byte) *wire.BARTID {
  318. return &wire.BARTID{
  319. Type: wire.BARTTypesBuddyIcon,
  320. BARTInfo: wire.BARTInfo{Flags: wire.BARTFlagsCustom, Hash: hash},
  321. }
  322. }
  323. func TestBuddyIconSource_URL(t *testing.T) {
  324. tests := []struct {
  325. name string
  326. baseURL string
  327. id *wire.BARTID
  328. idErr error
  329. want string
  330. }{
  331. {
  332. name: "user with an icon gets a URL carrying the icon hash",
  333. baseURL: "http://api.example.com",
  334. id: bartID([]byte{0xde, 0xad, 0xbe, 0xef}),
  335. want: "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=deadbeef",
  336. },
  337. {
  338. name: "user without an icon gets no URL",
  339. baseURL: "http://api.example.com",
  340. id: nil,
  341. want: "",
  342. },
  343. {
  344. name: "a cleared icon is not published",
  345. baseURL: "http://api.example.com",
  346. id: bartID(wire.GetClearIconHash()),
  347. want: "",
  348. },
  349. {
  350. name: "a lookup failure is not fatal, it just yields no icon",
  351. baseURL: "http://api.example.com",
  352. idErr: errors.New("db exploded"),
  353. want: "",
  354. },
  355. }
  356. for _, tt := range tests {
  357. t.Run(tt.name, func(t *testing.T) {
  358. iconRetriever := newMockBuddyIconRetriever(t)
  359. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("mikekelly")).
  360. Return(tt.id, tt.idErr).Once()
  361. s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
  362. got := s.URL(context.Background(), tt.baseURL, state.NewIdentScreenName("mikekelly"))
  363. assert.Equal(t, tt.want, got)
  364. })
  365. }
  366. }
  367. func TestBuddyIconSource_URL_NoBaseURLSkipsLookup(t *testing.T) {
  368. // Callers with no origin to build an absolute URL against opt out by passing
  369. // an empty baseURL. That must not cost a lookup.
  370. iconRetriever := newMockBuddyIconRetriever(t)
  371. s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
  372. got := s.URL(context.Background(), "", state.NewIdentScreenName("mikekelly"))
  373. assert.Empty(t, got)
  374. iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, mock.Anything)
  375. }
  376. func TestBuddyIconSource_URL_UsesNormalizedScreenName(t *testing.T) {
  377. // The URL targets the normalized screen name, which is what the endpoint
  378. // resolves against and what the client keys users by.
  379. iconRetriever := newMockBuddyIconRetriever(t)
  380. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).
  381. Return(bartID([]byte{0x01}), nil).Once()
  382. s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
  383. got := s.URL(context.Background(), "http://api.example.com", state.NewIdentScreenName("Mike Kelly"))
  384. assert.Equal(t, "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=01", got)
  385. }
  386. func TestBuddyIconSource_Image(t *testing.T) {
  387. hash := []byte{0xde, 0xad}
  388. iconRetriever := newMockBuddyIconRetriever(t)
  389. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("mikekelly")).
  390. Return(bartID(hash), nil).Once()
  391. // Image resolves the current hash from metadata, then downloads that exact
  392. // hash. The download query is keyed by hash; flags are irrelevant to the
  393. // lookup, so it carries only the type and hash.
  394. bartService := newMockBARTService(t)
  395. bartService.EXPECT().RetrieveItem(mock.Anything, wire.SNACFrame{}, wire.SNAC_0x10_0x04_BARTDownloadQuery{
  396. ScreenName: "mikekelly",
  397. BARTID: wire.BARTID{Type: wire.BARTTypesBuddyIcon, BARTInfo: wire.BARTInfo{Hash: hash}},
  398. }).Return(wire.SNACMessage{
  399. Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: iconGIF},
  400. }, nil).Once()
  401. s := BuddyIconSource{IconRetriever: iconRetriever, BARTService: bartService, Logger: slog.Default()}
  402. got, err := s.Image(context.Background(), state.NewIdentScreenName("mikekelly"))
  403. assert.NoError(t, err)
  404. assert.Equal(t, iconGIF, got)
  405. }
  406. func TestBuddyIconSource_ImageForHash(t *testing.T) {
  407. hash := []byte{0xca, 0xfe}
  408. // ImageForHash downloads the requested hash directly, without consulting the
  409. // user's current icon metadata.
  410. bartService := newMockBARTService(t)
  411. bartService.EXPECT().RetrieveItem(mock.Anything, wire.SNACFrame{}, wire.SNAC_0x10_0x04_BARTDownloadQuery{
  412. ScreenName: "mikekelly",
  413. BARTID: wire.BARTID{Type: wire.BARTTypesBuddyIcon, BARTInfo: wire.BARTInfo{Hash: hash}},
  414. }).Return(wire.SNACMessage{
  415. Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: iconGIF},
  416. }, nil).Once()
  417. s := BuddyIconSource{BARTService: bartService, Logger: slog.Default()}
  418. got, err := s.ImageForHash(context.Background(), state.NewIdentScreenName("mikekelly"), hash)
  419. assert.NoError(t, err)
  420. assert.Equal(t, iconGIF, got)
  421. }
  422. func TestBuddyIconSource_ImageForHash_NotFound(t *testing.T) {
  423. bartService := newMockBARTService(t)
  424. bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything, mock.Anything).
  425. Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{}}, nil).Once()
  426. s := BuddyIconSource{BARTService: bartService, Logger: slog.Default()}
  427. _, err := s.ImageForHash(context.Background(), state.NewIdentScreenName("mikekelly"), []byte{0x01})
  428. assert.ErrorIs(t, err, ErrNoBuddyIcon)
  429. }
  430. func TestBuddyIconSource_PublishedURL(t *testing.T) {
  431. tests := []struct {
  432. name string
  433. baseURL string
  434. id *wire.BARTID
  435. idErr error
  436. want string
  437. }{
  438. {
  439. name: "an icon yields a content-addressed URL",
  440. baseURL: "http://api.example.com",
  441. id: bartID([]byte{0xde, 0xad, 0xbe, 0xef}),
  442. want: "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=deadbeef",
  443. },
  444. {
  445. name: "no icon still yields a hash-less placeholder URL",
  446. baseURL: "http://api.example.com",
  447. id: nil,
  448. want: "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon",
  449. },
  450. {
  451. name: "a cleared icon yields the placeholder URL",
  452. baseURL: "http://api.example.com",
  453. id: bartID(wire.GetClearIconHash()),
  454. want: "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon",
  455. },
  456. {
  457. name: "a lookup failure yields no URL",
  458. baseURL: "http://api.example.com",
  459. idErr: errors.New("db exploded"),
  460. want: "",
  461. },
  462. {
  463. name: "no base URL yields no URL",
  464. baseURL: "",
  465. id: bartID([]byte{0x01}),
  466. want: "",
  467. },
  468. }
  469. for _, tt := range tests {
  470. t.Run(tt.name, func(t *testing.T) {
  471. iconRetriever := newMockBuddyIconRetriever(t)
  472. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("mikekelly")).
  473. Return(tt.id, tt.idErr).Maybe()
  474. s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
  475. got := s.PublishedURL(context.Background(), tt.baseURL, state.NewIdentScreenName("mikekelly"))
  476. assert.Equal(t, tt.want, got)
  477. })
  478. }
  479. }
  480. func TestBuddyIconSource_Image_NoIcon(t *testing.T) {
  481. iconRetriever := newMockBuddyIconRetriever(t)
  482. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).Return(nil, nil).Once()
  483. bartService := newMockBARTService(t)
  484. s := BuddyIconSource{IconRetriever: iconRetriever, BARTService: bartService, Logger: slog.Default()}
  485. _, err := s.Image(context.Background(), state.NewIdentScreenName("mikekelly"))
  486. assert.ErrorIs(t, err, ErrNoBuddyIcon)
  487. // No icon means there is nothing to ask BART for.
  488. bartService.AssertNotCalled(t, "RetrieveItem", mock.Anything, mock.Anything, mock.Anything)
  489. }
  490. func TestBuddyIconSource_URLForHash(t *testing.T) {
  491. // URLForHash never touches the retriever: the hash is supplied by the caller.
  492. s := BuddyIconSource{Logger: slog.Default()}
  493. sn := state.NewIdentScreenName("Mike Kelly")
  494. t.Run("hash yields the content-addressed URL", func(t *testing.T) {
  495. assert.Equal(t,
  496. "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=deadbeef",
  497. s.URLForHash("http://api.example.com", sn, []byte{0xde, 0xad, 0xbe, 0xef}))
  498. })
  499. t.Run("no hash yields the placeholder URL", func(t *testing.T) {
  500. assert.Equal(t,
  501. "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon",
  502. s.URLForHash("http://api.example.com", sn, nil))
  503. })
  504. t.Run("empty baseURL opts out", func(t *testing.T) {
  505. assert.Empty(t, s.URLForHash("", sn, []byte{0x01}))
  506. })
  507. }
  508. func TestBuddyIconSource_Image_RetrieveFails(t *testing.T) {
  509. iconRetriever := newMockBuddyIconRetriever(t)
  510. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).
  511. Return(bartID([]byte{0x01}), nil).Once()
  512. bartService := newMockBARTService(t)
  513. bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything, mock.Anything).
  514. Return(wire.SNACMessage{}, errors.New("item missing")).Once()
  515. s := BuddyIconSource{IconRetriever: iconRetriever, BARTService: bartService, Logger: slog.Default()}
  516. _, err := s.Image(context.Background(), state.NewIdentScreenName("mikekelly"))
  517. assert.ErrorContains(t, err, "item missing")
  518. assert.NotErrorIs(t, err, ErrNoBuddyIcon)
  519. }