| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622 |
- package webapi
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "testing"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
- "github.com/mk6i/open-oscar-server/state"
- "github.com/mk6i/open-oscar-server/wire"
- )
- // blankIconGIF stands in for the blank placeholder the real BART service returns
- // for the clear-icon hash; distinct from iconGIF so tests can tell them apart.
- var blankIconGIF = []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0xff}
- // newExpressionsHandler builds a handler whose target user has the given icon,
- // or no icon when id is nil. Its BART mock mirrors the real service: the
- // clear-icon hash resolves to the blank placeholder, any other hash to iconGIF.
- func newExpressionsHandler(t *testing.T, id *wire.BARTID) *ExpressionsHandler {
- iconRetriever := newMockBuddyIconRetriever(t)
- iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).Return(id, nil).Maybe()
- bartService := newMockBARTService(t)
- bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything,
- mock.MatchedBy(func(q wire.SNAC_0x10_0x04_BARTDownloadQuery) bool { return q.HasClearIconHash() })).
- Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: blankIconGIF}}, nil).Maybe()
- bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: iconGIF}}, nil).Maybe()
- return NewExpressionsHandler(BuddyIconSource{
- IconRetriever: iconRetriever,
- BARTService: bartService,
- Logger: slog.Default(),
- }, nil, nil, slog.Default())
- }
- func TestExpressionsHandler_Get_ServesIconBytes(t *testing.T) {
- h := newExpressionsHandler(t, bartID([]byte{0xde, 0xad}))
- r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead", nil)
- w := httptest.NewRecorder()
- h.Get(w, r)
- assert.Equal(t, http.StatusOK, w.Code)
- assert.Equal(t, iconGIF, w.Body.Bytes())
- assert.Equal(t, "image/gif", w.Header().Get("Content-Type"))
- // The URL pins the icon hash, so it always resolves to the same image.
- assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control"))
- }
- func TestExpressionsHandler_Get_IconWithoutHashIsNotCached(t *testing.T) {
- // Without a hash the URL keeps resolving to whatever the current icon is, so
- // caching it would pin a stale image.
- h := newExpressionsHandler(t, bartID([]byte{0xde, 0xad}))
- r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon", nil)
- w := httptest.NewRecorder()
- h.Get(w, r)
- assert.Equal(t, http.StatusOK, w.Code)
- assert.Equal(t, "no-cache", w.Header().Get("Cache-Control"))
- }
- func TestExpressionsHandler_Get_MissingIconServesPlaceholder(t *testing.T) {
- // A user with no icon still serves the blank placeholder for the hash-less
- // URL, so the client's <img> renders something and a cleared icon stops
- // showing the previous one rather than 404ing.
- h := newExpressionsHandler(t, nil)
- r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon", nil)
- w := httptest.NewRecorder()
- h.Get(w, r)
- assert.Equal(t, http.StatusOK, w.Code)
- assert.Equal(t, blankIconGIF, w.Body.Bytes())
- assert.Equal(t, "no-cache", w.Header().Get("Cache-Control"))
- }
- func TestExpressionsHandler_Get_BartIdServesRequestedHash(t *testing.T) {
- // Even though the user's *current* icon is hash B, a URL pinned to hash A must
- // serve A's bytes and cache immutably — otherwise a cached URL could later
- // resolve to a different image.
- iconRetriever := newMockBuddyIconRetriever(t)
- iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).
- Return(bartID([]byte{0xbb}), nil).Maybe()
- bytesA := []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0xa1}
- bartService := newMockBARTService(t)
- bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything,
- mock.MatchedBy(func(q wire.SNAC_0x10_0x04_BARTDownloadQuery) bool {
- return bytes.Equal(q.Hash, []byte{0xaa})
- })).
- Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: bytesA}}, nil).Once()
- h := NewExpressionsHandler(BuddyIconSource{
- IconRetriever: iconRetriever,
- BARTService: bartService,
- Logger: slog.Default(),
- }, nil, nil, slog.Default())
- r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon&bartId=aa", nil)
- w := httptest.NewRecorder()
- h.Get(w, r)
- assert.Equal(t, http.StatusOK, w.Code)
- assert.Equal(t, bytesA, w.Body.Bytes())
- assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control"))
- }
- func TestExpressionsHandler_Get_UnknownBartIdNotFound(t *testing.T) {
- iconRetriever := newMockBuddyIconRetriever(t)
- iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).
- Return(bartID([]byte{0xbb}), nil).Maybe()
- // An empty reply means the hash is not stored.
- bartService := newMockBARTService(t)
- bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{}}, nil).Once()
- h := NewExpressionsHandler(BuddyIconSource{
- IconRetriever: iconRetriever,
- BARTService: bartService,
- Logger: slog.Default(),
- }, nil, nil, slog.Default())
- r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon&bartId=abcdef", nil)
- w := httptest.NewRecorder()
- h.Get(w, r)
- assert.Equal(t, http.StatusNotFound, w.Code)
- }
- func TestExpressionsHandler_Get_ListsBigBuddyIcon(t *testing.T) {
- // This is the shape the client scans for its large icon rendering.
- h := newExpressionsHandler(t, bartID([]byte{0xde, 0xad}))
- r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly", nil)
- r.Host = "api.example.com"
- w := httptest.NewRecorder()
- h.Get(w, r)
- assert.Equal(t, http.StatusOK, w.Code)
- var got struct {
- Response struct {
- StatusCode int `json:"statusCode"`
- Data struct {
- Expressions []struct {
- Type string `json:"type"`
- URL string `json:"url"`
- } `json:"expressions"`
- } `json:"data"`
- } `json:"response"`
- }
- assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
- assert.Equal(t, 200, got.Response.StatusCode)
- assert.Len(t, got.Response.Data.Expressions, 1)
- assert.Equal(t, "bigBuddyIcon", got.Response.Data.Expressions[0].Type)
- assert.Equal(t,
- "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead",
- got.Response.Data.Expressions[0].URL)
- }
- func TestExpressionsHandler_Get_ListsNothingWithoutIcon(t *testing.T) {
- h := newExpressionsHandler(t, nil)
- r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly", nil)
- w := httptest.NewRecorder()
- h.Get(w, r)
- assert.Equal(t, http.StatusOK, w.Code)
- assert.Contains(t, w.Body.String(), `"expressions":[]`)
- }
- func TestExpressionsHandler_Get_Redirect(t *testing.T) {
- h := newExpressionsHandler(t, bartID([]byte{0xde, 0xad}))
- r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&f=redirect", nil)
- r.Host = "api.example.com"
- w := httptest.NewRecorder()
- h.Get(w, r)
- assert.Equal(t, http.StatusFound, w.Code)
- assert.Equal(t,
- "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead",
- w.Header().Get("Location"))
- }
- func TestExpressionsHandler_Get_RedirectWithoutIcon(t *testing.T) {
- h := newExpressionsHandler(t, nil)
- r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&f=redirect", nil)
- w := httptest.NewRecorder()
- h.Get(w, r)
- assert.Equal(t, http.StatusNotFound, w.Code)
- }
- func TestExpressionsHandler_Get_MissingTarget(t *testing.T) {
- h := newExpressionsHandler(t, nil)
- r := httptest.NewRequest(http.MethodGet, "/expressions/get", nil)
- w := httptest.NewRecorder()
- h.Get(w, r)
- assert.Equal(t, http.StatusBadRequest, w.Code)
- }
- func newUploadSession() *Session {
- return &Session{
- AimSID: "sid",
- ScreenName: state.DisplayScreenName("testuser"),
- OSCARSession: state.NewSession().AddInstance(),
- }
- }
- // The Android client posts the raw JPEG with a Content-Type of
- // application/x-www-form-urlencoded. The handler must read the body as bytes and
- // must not let anything parse it as a form.
- func TestExpressionsHandler_Upload_StoresAndPublishesIcon(t *testing.T) {
- image := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
- hash := []byte{0xde, 0xad, 0xbe, 0xef}
- uploader := newMockBARTService(t)
- uploader.EXPECT().UpsertItem(mock.Anything, mock.Anything,
- wire.SNACFrame{FoodGroup: wire.BART, SubGroup: wire.BARTUploadQuery},
- wire.SNAC_0x10_0x02_BARTUploadQuery{Type: wire.BARTTypesBuddyIcon, Data: image},
- ).Return(wire.SNACMessage{
- Body: wire.SNAC_0x10_0x03_BARTUploadReply{
- Code: wire.BARTReplyCodesSuccess,
- ID: wire.BARTID{
- Type: wire.BARTTypesBuddyIcon,
- BARTInfo: wire.BARTInfo{Flags: wire.BARTFlagsCustom, Hash: hash},
- },
- },
- }, nil).Once()
- fs := newMockFeedbagService(t)
- fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{
- Items: []wire.FeedbagItem{
- // The root group, which lives at GroupID 0 / ItemID 0.
- {ClassID: wire.FeedbagClassIdGroup},
- },
- }}, nil).Once()
- var (
- upserted []wire.FeedbagItem
- upsertFrame wire.SNACFrame
- )
- fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- Run(func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) {
- upsertFrame = inFrame
- upserted = items
- }).Return((*wire.SNACMessage)(nil), nil).Once()
- h := NewExpressionsHandler(BuddyIconSource{Logger: slog.Default()}, uploader, fs, slog.Default())
- req := httptest.NewRequest(http.MethodPost,
- "/expressions/upload?f=json&aimsid=sid&type=buddyIcon", bytes.NewReader(image))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- rr := httptest.NewRecorder()
- h.Upload(rr, req, newUploadSession())
- assert.Equal(t, http.StatusOK, rr.Code)
- var got struct {
- Response struct {
- StatusCode int `json:"statusCode"`
- Data struct {
- ID string `json:"id"`
- } `json:"data"`
- } `json:"response"`
- }
- assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
- assert.Equal(t, 200, got.Response.StatusCode)
- // type as 4 hex digits, then the content hash.
- assert.Equal(t, "0001deadbeef", got.Response.Data.ID)
- // The icon must be published to the feedbag, or expressions/get never sees it.
- assert.Len(t, upserted, 1)
- assert.Equal(t, wire.FeedbagClassIdBart, upserted[0].ClassID)
- assert.Equal(t, "1", upserted[0].Name)
- // A first icon must not land on the root group's (0, 0) row.
- assert.NotZero(t, upserted[0].ItemID)
- assert.Equal(t, uint16(wire.FeedbagInsertItem), upsertFrame.SubGroup)
- b, ok := upserted[0].Bytes(wire.FeedbagAttributesBartInfo)
- assert.True(t, ok)
- info := wire.BARTInfo{}
- assert.NoError(t, wire.UnmarshalBE(&info, bytes.NewBuffer(b)))
- assert.Equal(t, hash, info.Hash)
- }
- // An existing icon is replaced in place rather than added alongside.
- func TestExpressionsHandler_Upload_ReusesExistingFeedbagItem(t *testing.T) {
- uploader := newMockBARTService(t)
- uploader.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{
- Body: wire.SNAC_0x10_0x03_BARTUploadReply{
- Code: wire.BARTReplyCodesSuccess,
- ID: wire.BARTID{Type: wire.BARTTypesBuddyIcon, BARTInfo: wire.BARTInfo{Hash: []byte{0x01}}},
- },
- }, nil).Once()
- fs := newMockFeedbagService(t)
- fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{
- Items: []wire.FeedbagItem{
- {GroupID: 7, ItemID: 9, ClassID: wire.FeedbagClassIdBart, Name: "1"},
- },
- }}, nil).Once()
- var (
- upserted []wire.FeedbagItem
- upsertFrame wire.SNACFrame
- )
- fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- Run(func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) {
- upsertFrame = inFrame
- upserted = items
- }).
- Return((*wire.SNACMessage)(nil), nil).Once()
- h := NewExpressionsHandler(BuddyIconSource{Logger: slog.Default()}, uploader, fs, slog.Default())
- req := httptest.NewRequest(http.MethodPost,
- "/expressions/upload?type=buddyIcon", bytes.NewReader([]byte{0x01, 0x02}))
- rr := httptest.NewRecorder()
- h.Upload(rr, req, newUploadSession())
- assert.Equal(t, http.StatusOK, rr.Code)
- assert.Len(t, upserted, 1)
- assert.Equal(t, uint16(7), upserted[0].GroupID)
- assert.Equal(t, uint16(9), upserted[0].ItemID)
- // Other instances are relayed this frame verbatim, so replacing an icon has
- // to go out as an update of an item they already hold, not an insert.
- assert.Equal(t, uint16(wire.FeedbagUpdateItem), upsertFrame.SubGroup)
- }
- func TestExpressionsHandler_Upload_RejectsBadRequests(t *testing.T) {
- cases := []struct {
- name string
- url string
- body []byte
- }{
- {"missing type", "/expressions/upload?f=json", []byte{0x01}},
- {"unsupported type", "/expressions/upload?type=wallpaper", []byte{0x01}},
- {"empty body", "/expressions/upload?type=buddyIcon", nil},
- {"oversized body", "/expressions/upload?type=buddyIcon", make([]byte, bartUploadMaxBytes+1)},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- uploader := newMockBARTService(t)
- fs := newMockFeedbagService(t)
- h := NewExpressionsHandler(BuddyIconSource{Logger: slog.Default()}, uploader, fs, slog.Default())
- req := httptest.NewRequest(http.MethodPost, tc.url, bytes.NewReader(tc.body))
- rr := httptest.NewRecorder()
- h.Upload(rr, req, newUploadSession())
- assert.Equal(t, http.StatusBadRequest, rr.Code)
- // Nothing may be stored or published when the request is rejected.
- uploader.AssertNotCalled(t, "UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
- fs.AssertNotCalled(t, "UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
- })
- }
- }
- // iconGIF stands in for buddy icon image bytes.
- var iconGIF = []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00}
- func bartID(hash []byte) *wire.BARTID {
- return &wire.BARTID{
- Type: wire.BARTTypesBuddyIcon,
- BARTInfo: wire.BARTInfo{Flags: wire.BARTFlagsCustom, Hash: hash},
- }
- }
- func TestBuddyIconSource_URL(t *testing.T) {
- tests := []struct {
- name string
- baseURL string
- id *wire.BARTID
- idErr error
- want string
- }{
- {
- name: "user with an icon gets a URL carrying the icon hash",
- baseURL: "http://api.example.com",
- id: bartID([]byte{0xde, 0xad, 0xbe, 0xef}),
- want: "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=deadbeef",
- },
- {
- name: "user without an icon gets no URL",
- baseURL: "http://api.example.com",
- id: nil,
- want: "",
- },
- {
- name: "a cleared icon is not published",
- baseURL: "http://api.example.com",
- id: bartID(wire.GetClearIconHash()),
- want: "",
- },
- {
- name: "a lookup failure is not fatal, it just yields no icon",
- baseURL: "http://api.example.com",
- idErr: errors.New("db exploded"),
- want: "",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- iconRetriever := newMockBuddyIconRetriever(t)
- iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("mikekelly")).
- Return(tt.id, tt.idErr).Once()
- s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
- got := s.URL(context.Background(), tt.baseURL, state.NewIdentScreenName("mikekelly"))
- assert.Equal(t, tt.want, got)
- })
- }
- }
- func TestBuddyIconSource_URL_NoBaseURLSkipsLookup(t *testing.T) {
- // Callers with no origin to build an absolute URL against opt out by passing
- // an empty baseURL. That must not cost a lookup.
- iconRetriever := newMockBuddyIconRetriever(t)
- s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
- got := s.URL(context.Background(), "", state.NewIdentScreenName("mikekelly"))
- assert.Empty(t, got)
- iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, mock.Anything)
- }
- func TestBuddyIconSource_URL_UsesNormalizedScreenName(t *testing.T) {
- // The URL targets the normalized screen name, which is what the endpoint
- // resolves against and what the client keys users by.
- iconRetriever := newMockBuddyIconRetriever(t)
- iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).
- Return(bartID([]byte{0x01}), nil).Once()
- s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
- got := s.URL(context.Background(), "http://api.example.com", state.NewIdentScreenName("Mike Kelly"))
- assert.Equal(t, "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=01", got)
- }
- func TestBuddyIconSource_Image(t *testing.T) {
- hash := []byte{0xde, 0xad}
- iconRetriever := newMockBuddyIconRetriever(t)
- iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("mikekelly")).
- Return(bartID(hash), nil).Once()
- // Image resolves the current hash from metadata, then downloads that exact
- // hash. The download query is keyed by hash; flags are irrelevant to the
- // lookup, so it carries only the type and hash.
- bartService := newMockBARTService(t)
- bartService.EXPECT().RetrieveItem(mock.Anything, wire.SNACFrame{}, wire.SNAC_0x10_0x04_BARTDownloadQuery{
- ScreenName: "mikekelly",
- BARTID: wire.BARTID{Type: wire.BARTTypesBuddyIcon, BARTInfo: wire.BARTInfo{Hash: hash}},
- }).Return(wire.SNACMessage{
- Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: iconGIF},
- }, nil).Once()
- s := BuddyIconSource{IconRetriever: iconRetriever, BARTService: bartService, Logger: slog.Default()}
- got, err := s.Image(context.Background(), state.NewIdentScreenName("mikekelly"))
- assert.NoError(t, err)
- assert.Equal(t, iconGIF, got)
- }
- func TestBuddyIconSource_ImageForHash(t *testing.T) {
- hash := []byte{0xca, 0xfe}
- // ImageForHash downloads the requested hash directly, without consulting the
- // user's current icon metadata.
- bartService := newMockBARTService(t)
- bartService.EXPECT().RetrieveItem(mock.Anything, wire.SNACFrame{}, wire.SNAC_0x10_0x04_BARTDownloadQuery{
- ScreenName: "mikekelly",
- BARTID: wire.BARTID{Type: wire.BARTTypesBuddyIcon, BARTInfo: wire.BARTInfo{Hash: hash}},
- }).Return(wire.SNACMessage{
- Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: iconGIF},
- }, nil).Once()
- s := BuddyIconSource{BARTService: bartService, Logger: slog.Default()}
- got, err := s.ImageForHash(context.Background(), state.NewIdentScreenName("mikekelly"), hash)
- assert.NoError(t, err)
- assert.Equal(t, iconGIF, got)
- }
- func TestBuddyIconSource_ImageForHash_NotFound(t *testing.T) {
- bartService := newMockBARTService(t)
- bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{}}, nil).Once()
- s := BuddyIconSource{BARTService: bartService, Logger: slog.Default()}
- _, err := s.ImageForHash(context.Background(), state.NewIdentScreenName("mikekelly"), []byte{0x01})
- assert.ErrorIs(t, err, ErrNoBuddyIcon)
- }
- func TestBuddyIconSource_PublishedURL(t *testing.T) {
- tests := []struct {
- name string
- baseURL string
- id *wire.BARTID
- idErr error
- want string
- }{
- {
- name: "an icon yields a content-addressed URL",
- baseURL: "http://api.example.com",
- id: bartID([]byte{0xde, 0xad, 0xbe, 0xef}),
- want: "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=deadbeef",
- },
- {
- name: "no icon still yields a hash-less placeholder URL",
- baseURL: "http://api.example.com",
- id: nil,
- want: "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon",
- },
- {
- name: "a cleared icon yields the placeholder URL",
- baseURL: "http://api.example.com",
- id: bartID(wire.GetClearIconHash()),
- want: "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon",
- },
- {
- name: "a lookup failure yields no URL",
- baseURL: "http://api.example.com",
- idErr: errors.New("db exploded"),
- want: "",
- },
- {
- name: "no base URL yields no URL",
- baseURL: "",
- id: bartID([]byte{0x01}),
- want: "",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- iconRetriever := newMockBuddyIconRetriever(t)
- iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("mikekelly")).
- Return(tt.id, tt.idErr).Maybe()
- s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
- got := s.PublishedURL(context.Background(), tt.baseURL, state.NewIdentScreenName("mikekelly"))
- assert.Equal(t, tt.want, got)
- })
- }
- }
- func TestBuddyIconSource_Image_NoIcon(t *testing.T) {
- iconRetriever := newMockBuddyIconRetriever(t)
- iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).Return(nil, nil).Once()
- bartService := newMockBARTService(t)
- s := BuddyIconSource{IconRetriever: iconRetriever, BARTService: bartService, Logger: slog.Default()}
- _, err := s.Image(context.Background(), state.NewIdentScreenName("mikekelly"))
- assert.ErrorIs(t, err, ErrNoBuddyIcon)
- // No icon means there is nothing to ask BART for.
- bartService.AssertNotCalled(t, "RetrieveItem", mock.Anything, mock.Anything, mock.Anything)
- }
- func TestBuddyIconSource_URLForHash(t *testing.T) {
- // URLForHash never touches the retriever: the hash is supplied by the caller.
- s := BuddyIconSource{Logger: slog.Default()}
- sn := state.NewIdentScreenName("Mike Kelly")
- t.Run("hash yields the content-addressed URL", func(t *testing.T) {
- assert.Equal(t,
- "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=deadbeef",
- s.URLForHash("http://api.example.com", sn, []byte{0xde, 0xad, 0xbe, 0xef}))
- })
- t.Run("no hash yields the placeholder URL", func(t *testing.T) {
- assert.Equal(t,
- "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon",
- s.URLForHash("http://api.example.com", sn, nil))
- })
- t.Run("empty baseURL opts out", func(t *testing.T) {
- assert.Empty(t, s.URLForHash("", sn, []byte{0x01}))
- })
- }
- func TestBuddyIconSource_Image_RetrieveFails(t *testing.T) {
- iconRetriever := newMockBuddyIconRetriever(t)
- iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).
- Return(bartID([]byte{0x01}), nil).Once()
- bartService := newMockBARTService(t)
- bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{}, errors.New("item missing")).Once()
- s := BuddyIconSource{IconRetriever: iconRetriever, BARTService: bartService, Logger: slog.Default()}
- _, err := s.Image(context.Background(), state.NewIdentScreenName("mikekelly"))
- assert.ErrorContains(t, err, "item missing")
- assert.NotErrorIs(t, err, ErrNoBuddyIcon)
- }
|