| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682 |
- package webapi
- import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
- "github.com/stretchr/testify/require"
- "github.com/mk6i/open-oscar-server/state"
- "github.com/mk6i/open-oscar-server/wire"
- )
- // onlineUserInfoReply builds a locate UserInfoReply for an online user,
- // optionally marking them idle by the given number of minutes (0 = not idle).
- func onlineUserInfoReply(screenName string, idleMinutes uint16) wire.SNACMessage {
- info := wire.TLVUserInfo{ScreenName: screenName}
- if idleMinutes > 0 {
- info.Append(wire.NewTLVBE(wire.OServiceUserInfoIdleTime, idleMinutes))
- }
- return wire.SNACMessage{
- Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{TLVUserInfo: info},
- }
- }
- // screenNameMatcher matches a UserInfoQuery request body by its target screen name.
- func screenNameMatcher(screenName string) any {
- return mock.MatchedBy(func(b wire.SNAC_0x02_0x05_LocateUserInfoQuery) bool {
- return b.ScreenName == screenName
- })
- }
- func TestPresenceHandler_GetPresence(t *testing.T) {
- tests := []struct {
- name string
- queryParams string
- setupMocks func(*mockFeedbagService, *mockLocateService)
- seedPresence func(*Session)
- expectedStatusCode int
- checkResponse func(*testing.T, string)
- }{
- {
- name: "Success_BuddyList",
- queryParams: "bl=1",
- setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
- // Return feedbag with a group and buddy
- fr.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{
- Body: wire.SNAC_0x13_0x06_FeedbagReply{
- Items: []wire.FeedbagItem{
- {ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 1},
- {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "buddy1", GroupID: 1},
- },
- },
- }, nil)
- },
- // bl=1 is served from the presence view, so the state comes from the
- // buddy's arrival.
- seedPresence: func(sess *Session) {
- buddyArrives(sess, onlineBuddy("buddy1"))
- },
- expectedStatusCode: http.StatusOK,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, `"statusCode":200`)
- assert.Contains(t, body, `"groups"`)
- assert.Contains(t, body, `"Friends"`)
- assert.Contains(t, body, `"buddy1"`)
- assert.Contains(t, body, `"online"`)
- },
- },
- {
- name: "Success_TargetUsers",
- queryParams: "t=user1,user2",
- setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
- ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user1")).
- Return(onlineUserInfoReply("user1", 0), nil)
- // user2 is idle for 7 minutes.
- ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user2")).
- Return(onlineUserInfoReply("user2", 7), nil)
- },
- expectedStatusCode: http.StatusOK,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, `"statusCode":200`)
- assert.Contains(t, body, `"users"`)
- assert.Contains(t, body, `"user1"`)
- assert.Contains(t, body, `"user2"`)
- assert.Contains(t, body, `"idle"`)
- },
- },
- {
- name: "Success_BlockedOrOfflineUser",
- queryParams: "t=blockeduser",
- setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
- // A blocked or offline user comes back as a locate error.
- ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("blockeduser")).
- Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
- },
- expectedStatusCode: http.StatusOK,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, `"statusCode":200`)
- assert.Contains(t, body, `"blockeduser"`)
- assert.Contains(t, body, `"offline"`)
- },
- },
- {
- name: "Success_EmptyRequest",
- queryParams: "",
- setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {},
- expectedStatusCode: http.StatusOK,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, `"statusCode":200`)
- },
- },
- {
- // A full search page: memberDir/search returns 20 profiles and the
- // client asks about every one, plus the keyword when it is a UIN.
- // This is the largest list a real client sends, and it must be served
- // whole — anything but a 200 costs the user the entire page.
- name: "Success_FullSearchPage",
- queryParams: "t=" + strings.Join(searchPageTargets(21), "&t="),
- setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
- ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, body wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
- return onlineUserInfoReply(body.ScreenName, 0), nil
- }).Times(21)
- },
- expectedStatusCode: http.StatusOK,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, `"statusCode":200`)
- assert.Contains(t, body, `"user0"`)
- assert.Contains(t, body, `"user20"`)
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- feedbagService := newMockFeedbagService(t)
- locateService := newMockLocateService(t)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- tt.setupMocks(feedbagService, locateService)
- // Presence payloads carry the viewer's alias, so GetPresence reads the
- // feedbag. Registered last so a case's own Query stub takes precedence.
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
- // Seeded after the stubs: an arrival resolves aliases off the feedbag.
- sess, err := sessionMgr.GetSession(context.Background(), aimsid)
- require.NoError(t, err)
- sess.FeedbagLoader = feedbagServiceLoader(feedbagService, oscarInstance)
- if tt.seedPresence != nil {
- tt.seedPresence(sess)
- }
- reqURL := "/presence/get?aimsid=" + aimsid
- if tt.queryParams != "" {
- reqURL += "&" + tt.queryParams
- }
- req, err := http.NewRequest("GET", reqURL, nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.Equal(t, tt.expectedStatusCode, rr.Code)
- if tt.checkResponse != nil {
- responseBody := strings.TrimSpace(rr.Body.String())
- tt.checkResponse(t, responseBody)
- }
- })
- }
- }
- // TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly verifies that
- // the icon is published only for an online, non-blocking user, and that an
- // offline or blocking user is never even looked up — so neither their icon nor
- // its hash leaks to a caller they are invisible to.
- func TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly(t *testing.T) {
- ctx := context.Background()
- feedbagService := newMockFeedbagService(t)
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("onlineuser")).
- Return(onlineUserInfoReply("onlineuser", 0), nil)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("offlineuser")).
- Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
- iconRetriever := newMockBuddyIconRetriever(t)
- iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("onlineuser")).
- Return(bartID([]byte{0xab, 0xcd}), nil).Once()
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- sess, err := sessionMgr.GetSession(ctx, aimsid)
- require.NoError(t, err)
- sess.BaseURL = "http://api.example.com"
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- IconSource: BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=onlineuser,offlineuser", nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- require.Equal(t, http.StatusOK, rr.Code)
- var got struct {
- Response struct {
- Data struct {
- Users []struct {
- AimID string `json:"aimId"`
- State string `json:"state"`
- BuddyIcon string `json:"buddyIcon"`
- } `json:"users"`
- } `json:"data"`
- } `json:"response"`
- }
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
- icons := map[string]string{}
- states := map[string]string{}
- for _, u := range got.Response.Data.Users {
- icons[u.AimID] = u.BuddyIcon
- states[u.AimID] = u.State
- }
- assert.Equal(t, "online", states["onlineuser"])
- assert.Equal(t,
- "http://api.example.com/expressions/get?t=onlineuser&type=buddyIcon&bartId=abcd",
- icons["onlineuser"])
- assert.Equal(t, "offline", states["offlineuser"])
- assert.Empty(t, icons["offlineuser"])
- iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("offlineuser"))
- }
- // TestPresenceHandler_GetPresence_BuddyListGrouping verifies that bl=1 places
- // each buddy under its own group using realistic feedbag data, where group rows
- // carry ItemID 0 and a distinct nonzero GroupID, and buddy rows reference those
- // GroupIDs. This is the shape the OSCAR feedbag actually stores.
- // A buddy's status message rides in the BART TLV, next to the icon reference.
- func TestPresenceHandler_GetPresence_PublishesStatusMessage(t *testing.T) {
- ctx := context.Background()
- withStatus := wire.TLVUserInfo{ScreenName: "hasstatus"}
- withStatus.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testStatusBART}))
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("hasstatus")).
- Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{TLVUserInfo: withStatus}}, nil)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("nostatus")).
- Return(onlineUserInfoReply("nostatus", 0), nil)
- iconRetriever := newMockBuddyIconRetriever(t)
- iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- sess, err := sessionMgr.GetSession(ctx, aimsid)
- require.NoError(t, err)
- sess.BaseURL = "http://api.example.com"
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- LocateService: locateService,
- IconSource: BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=hasstatus,nostatus", nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- require.Equal(t, http.StatusOK, rr.Code)
- var got struct {
- Response struct {
- Data struct {
- Users []struct {
- AimID string `json:"aimId"`
- StatusMsg string `json:"statusMsg"`
- } `json:"users"`
- } `json:"data"`
- } `json:"response"`
- }
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
- require.Len(t, got.Response.Data.Users, 2)
- byName := map[string]string{}
- for _, u := range got.Response.Data.Users {
- byName[u.AimID] = u.StatusMsg
- }
- assert.Equal(t, "brb", byName["hasstatus"])
- assert.Empty(t, byName["nostatus"])
- }
- func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
- feedbagService := newMockFeedbagService(t)
- locateService := newMockLocateService(t)
- items := []wire.FeedbagItem{
- // Root order group: ItemID 0, GroupID 0, empty name — not a real buddy group.
- {ItemID: 0, GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
- // Named groups: ItemID 0, distinct nonzero GroupIDs.
- {ItemID: 0, GroupID: 10, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
- {ItemID: 0, GroupID: 20, ClassID: wire.FeedbagClassIdGroup, Name: "Work"},
- // Buddies reference their group's GroupID.
- {ItemID: 101, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "alice"},
- {ItemID: 201, GroupID: 20, ClassID: wire.FeedbagClassIdBuddy, Name: "bob"},
- }
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- sess, err := sessionMgr.GetSession(context.Background(), aimsid)
- require.NoError(t, err)
- sess.FeedbagLoader = feedbagServiceLoader(feedbagService, oscarInstance)
- buddyArrives(sess, onlineBuddy("alice"))
- buddyArrives(sess, onlineBuddy("bob"))
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&bl=1", nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- var parsed struct {
- Response struct {
- Data struct {
- Groups []struct {
- Name string `json:"name"`
- Buddies []struct {
- AimID string `json:"aimId"`
- } `json:"buddies"`
- } `json:"groups"`
- } `json:"data"`
- } `json:"response"`
- }
- assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &parsed))
- // Build name -> set of buddy aimIds.
- byGroup := map[string][]string{}
- for _, g := range parsed.Response.Data.Groups {
- for _, b := range g.Buddies {
- byGroup[g.Name] = append(byGroup[g.Name], b.AimID)
- }
- }
- // Exactly the two named groups appear; the root group is excluded.
- assert.Len(t, parsed.Response.Data.Groups, 2)
- assert.Equal(t, []string{"alice"}, byGroup["Friends"])
- assert.Equal(t, []string{"bob"}, byGroup["Work"])
- }
- func TestPresenceHandler_GetPresence_MissingAimsid(t *testing.T) {
- handler := &PresenceHandler{
- SessionManager: NewSessionManager(),
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusBadRequest, rr.Code)
- assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
- }
- func TestPresenceHandler_GetPresence_SessionNotFound(t *testing.T) {
- handler := &PresenceHandler{
- SessionManager: NewSessionManager(),
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get?aimsid=nonexistent", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusUnauthorized, rr.Code)
- assert.Contains(t, rr.Body.String(), "invalid or expired session")
- }
- func TestPresenceHandler_SetState_MissingAimsid(t *testing.T) {
- handler := &PresenceHandler{
- SessionManager: NewSessionManager(),
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setState", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusBadRequest, rr.Code)
- assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
- }
- func TestPresenceHandler_SetState_InvalidState(t *testing.T) {
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=bogus", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusBadRequest, rr.Code)
- assert.Contains(t, rr.Body.String(), "invalid state parameter")
- }
- func TestPresenceHandler_SetState_AppliesAwayState(t *testing.T) {
- // setState sets the away message and the status bits through the OSCAR
- // services; the identity badge re-renders from the myInfo the user info
- // update raises, so the handler queues no event of its own.
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- locateService := newMockLocateService(t)
- locateService.EXPECT().
- SetInfo(mock.Anything, oscarInstance, wire.SNAC_0x02_0x04_LocateSetInfo{
- TLVRestBlock: wire.TLVRestBlock{
- TLVList: wire.TLVList{
- wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "brb"),
- },
- },
- }).
- Return(nil)
- oserviceService := newMockOServiceService(t)
- oserviceService.EXPECT().
- SetUserInfoFields(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
- TLVRestBlock: wire.TLVRestBlock{
- TLVList: wire.TLVList{
- wire.NewTLVBE(wire.OServiceUserInfoStatus, wire.OServiceUserStatusAway),
- },
- },
- }).
- Return(nil)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- LocateService: locateService,
- OServiceService: oserviceService,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away&awayMsg=brb", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- var resp struct {
- Response struct {
- Data SetStateData `json:"data"`
- } `json:"response"`
- }
- assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
- assert.Equal(t, "away", resp.Response.Data.State)
- assert.Equal(t, "brb", resp.Response.Data.AwayMsg)
- assert.Equal(t, "testuser", resp.Response.Data.AimID)
- session, err := sessionMgr.GetSession(context.Background(), aimsid)
- assert.NoError(t, err)
- assert.Empty(t, session.EventQueue.GetAllEvents())
- }
- func TestPresenceHandler_SetState_OnlineClearsAwayMessage(t *testing.T) {
- // Coming back online drops the away message, which LocateSetInfo owns, along
- // with the status bits.
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- locateService := newMockLocateService(t)
- locateService.EXPECT().
- SetInfo(mock.Anything, oscarInstance, wire.SNAC_0x02_0x04_LocateSetInfo{
- TLVRestBlock: wire.TLVRestBlock{
- TLVList: wire.TLVList{
- wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, ""),
- },
- },
- }).
- Return(nil)
- oserviceService := newMockOServiceService(t)
- oserviceService.EXPECT().
- SetUserInfoFields(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
- TLVRestBlock: wire.TLVRestBlock{
- TLVList: wire.TLVList{
- wire.NewTLVBE(wire.OServiceUserInfoStatus, uint32(0)),
- },
- },
- }).
- Return(nil)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- LocateService: locateService,
- OServiceService: oserviceService,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=online&awayMsg=brb", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- assert.Contains(t, rr.Body.String(), `"awayMsg":""`)
- }
- func TestPresenceHandler_SetState_ServiceError(t *testing.T) {
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- oserviceService := newMockOServiceService(t)
- oserviceService.EXPECT().
- SetUserInfoFields(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- Return(assert.AnError)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- OServiceService: oserviceService,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=dnd", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusInternalServerError, rr.Code)
- }
- func TestPresenceHandler_SetState_NormalizesAimID(t *testing.T) {
- // The client keys users by the normalized aimId, so the response must carry
- // that while displayId keeps the user's own casing and spacing.
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("Mike Kelly", oscarInstance)
- // An away state with no message leaves the stored away message alone, so the
- // handler sends the status change only.
- oserviceService := newMockOServiceService(t)
- oserviceService.EXPECT().
- SetUserInfoFields(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
- TLVRestBlock: wire.TLVRestBlock{
- TLVList: wire.TLVList{
- wire.NewTLVBE(wire.OServiceUserInfoStatus, wire.OServiceUserStatusAway),
- },
- },
- }).
- Return(nil)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- OServiceService: oserviceService,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- // The setState response body carries the same identity fields.
- var resp struct {
- Response struct {
- Data map[string]any `json:"data"`
- } `json:"response"`
- }
- assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
- assert.Equal(t, "mikekelly", resp.Response.Data["aimId"])
- assert.Equal(t, "Mike Kelly", resp.Response.Data["displayId"])
- }
- func TestPresenceHandler_Icon(t *testing.T) {
- tests := []struct {
- name string
- queryParams string
- expectedStatusCode int
- checkRedirect func(*testing.T, *httptest.ResponseRecorder)
- }{
- {
- name: "Redirect_OfflineUser",
- // No aimsid, so there is no OSCAR session to query on behalf of and
- // the target resolves to offline.
- queryParams: "name=offlineuser",
- expectedStatusCode: http.StatusFound,
- checkRedirect: func(t *testing.T, rr *httptest.ResponseRecorder) {
- location := rr.Header().Get("Location")
- assert.Contains(t, location, "offline")
- },
- },
- {
- name: "Error_MissingName",
- queryParams: "",
- expectedStatusCode: http.StatusBadRequest,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- handler := &PresenceHandler{
- SessionManager: NewSessionManager(),
- LocateService: newMockLocateService(t),
- Logger: slog.Default(),
- }
- reqURL := "/presence/icon"
- if tt.queryParams != "" {
- reqURL += "?" + tt.queryParams
- }
- req, err := http.NewRequest("GET", reqURL, nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- handler.Icon(rr, req)
- assert.Equal(t, tt.expectedStatusCode, rr.Code)
- if tt.checkRedirect != nil {
- tt.checkRedirect(t, rr)
- }
- })
- }
- }
- func TestPresenceHandler_SetProfile(t *testing.T) {
- oscarInstance := state.NewSession().AddInstance()
- tests := []struct {
- name string
- queryParams string
- setupMocks func(*mockLocateService)
- expectedStatusCode int
- checkResponse func(*testing.T, string)
- }{
- {
- name: "Success_SetProfile",
- queryParams: "profile=Hello+World",
- setupMocks: func(ls *mockLocateService) {
- ls.EXPECT().SetInfo(mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNAC_0x02_0x04_LocateSetInfo")).Return(nil)
- },
- expectedStatusCode: http.StatusOK,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, `"statusCode":200`)
- },
- },
- {
- name: "Error_ProfileTooLarge",
- queryParams: "profile=" + strings.Repeat("x", 4097),
- setupMocks: func(ls *mockLocateService) {},
- expectedStatusCode: http.StatusBadRequest,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, "profile too large")
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- locateService := newMockLocateService(t)
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- tt.setupMocks(locateService)
- reqURL := "/presence/setProfile?aimsid=" + aimsid + "&" + tt.queryParams
- req, err := http.NewRequest("GET", reqURL, nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetProfile).ServeHTTP(rr, req)
- assert.Equal(t, tt.expectedStatusCode, rr.Code)
- if tt.checkResponse != nil {
- responseBody := strings.TrimSpace(rr.Body.String())
- tt.checkResponse(t, responseBody)
- }
- })
- }
- }
- func TestPresenceHandler_GetProfile(t *testing.T) {
- locateService := newMockLocateService(t)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("testuser")).
- Return(wire.SNACMessage{
- Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
- LocateInfo: wire.TLVRestBlock{
- TLVList: wire.TLVList{
- wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "My profile"),
- },
- },
- },
- }, nil)
- req, err := http.NewRequest("GET", "/presence/getProfile?aimsid="+aimsid, nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetProfile).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- body := rr.Body.String()
- assert.Contains(t, body, `"statusCode":200`)
- assert.Contains(t, body, `"My profile"`)
- assert.Contains(t, body, `"testuser"`)
- }
- func TestPresenceHandler_SetState_Occupied(t *testing.T) {
- // ICQ's Busy, which is a selectable connect state and must be accepted. An AIM
- // caller is told "away" instead, since AIM 8 draws "occupied" as offline.
- tests := []struct {
- name string
- screenName string
- wantState string
- }{
- {name: "icq account", screenName: "100003", wantState: "occupied"},
- {name: "aim account", screenName: "testuser", wantState: "away"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // The account that decides the reported state is read off the OSCAR session.
- oscarSession := state.NewSession()
- oscarSession.SetIdentScreenName(state.NewIdentScreenName(tt.screenName))
- oscarInstance := oscarSession.AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR(tt.screenName, oscarInstance)
- oserviceService := newMockOServiceService(t)
- oserviceService.EXPECT().
- SetUserInfoFields(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
- TLVRestBlock: wire.TLVRestBlock{
- TLVList: wire.TLVList{
- wire.NewTLVBE(wire.OServiceUserInfoStatus, wire.OServiceUserStatusBusy),
- },
- },
- }).
- // stand in for the service, which applies the status to the session
- Run(func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) {
- status, _ := inBody.Uint32BE(wire.OServiceUserInfoStatus)
- instance.SetUserStatusBitmask(status)
- }).
- Return(nil)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- OServiceService: oserviceService,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&view=occupied&away=", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- // The Busy bit reaches OSCAR either way; only the reported state differs.
- assert.Contains(t, rr.Body.String(), `"state":"`+tt.wantState+`"`)
- // The state must survive the round trip: the myInfo raised by the
- // user info update reads the Busy bit back off the wire.
- assert.Equal(t, tt.wantState, selfWebState(oscarSession.TLVUserInfo(), oscarSession.IdentScreenName().UIN() == 0))
- })
- }
- }
- func TestPresenceHandler_GetPresence_MdirAttachesProfile(t *testing.T) {
- // Search results are populated from this call, and a user carrying no nested
- // "profile" object is dropped by clients, so a found user would not render.
- ctx := context.Background()
- dirReply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
- dirReply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, "Bob"))
- dirReply.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Smith"))
- dirReply.Append(wire.NewTLVBE(wire.ODirTLVCity, "Reno"))
- feedbagService := newMockFeedbagService(t)
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- Return(onlineUserInfoReply("founduser", 0), nil)
- locateService.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: dirReply}, nil)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- _, err := sessionMgr.GetSession(ctx, aimsid)
- require.NoError(t, err)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&mdir=1&t=founduser", nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- require.Equal(t, http.StatusOK, rr.Code)
- var got struct {
- Response struct {
- Data struct {
- Users []struct {
- AimID string `json:"aimId"`
- Profile *BuddyProfileInfo `json:"profile"`
- } `json:"users"`
- } `json:"data"`
- } `json:"response"`
- }
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
- require.Len(t, got.Response.Data.Users, 1)
- profile := got.Response.Data.Users[0].Profile
- require.NotNil(t, profile, "mdir=1 must carry a profile object")
- assert.Equal(t, "Bob", profile.FirstName)
- assert.Equal(t, "Smith", profile.LastName)
- require.Len(t, profile.HomeAddress, 1)
- assert.Equal(t, "Reno", profile.HomeAddress[0].City)
- }
- func TestPresenceHandler_GetPresence_MdirProfileIsEmptyNotAbsent(t *testing.T) {
- // A user with no directory record must still render, or a freshly created
- // account can never be found.
- ctx := context.Background()
- feedbagService := newMockFeedbagService(t)
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- Return(onlineUserInfoReply("blankuser", 0), nil)
- locateService.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}}, nil)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- _, err := sessionMgr.GetSession(ctx, aimsid)
- require.NoError(t, err)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&mdir=1&t=blankuser", nil)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- // The key must be present and non-null, which is what the client tests for.
- assert.Contains(t, rr.Body.String(), `"profile":{}`)
- }
- func TestPresenceHandler_GetPresence_NoMdirOmitsProfile(t *testing.T) {
- // Without mdir the directory is not consulted at all — the mock asserts that by
- // having no DirInfo expectation.
- ctx := context.Background()
- feedbagService := newMockFeedbagService(t)
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- Return(onlineUserInfoReply("someuser", 0), nil)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- _, err := sessionMgr.GetSession(ctx, aimsid)
- require.NoError(t, err)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=someuser", nil)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.NotContains(t, rr.Body.String(), `"profile"`)
- }
- func TestPresenceHandler_GetPresence_EmptyQueryRendersBothArrays(t *testing.T) {
- ctx := context.Background()
- // No expectations on either service: naming no list must cost no lookups.
- feedbagService := newMockFeedbagService(t)
- locateService := newMockLocateService(t)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- _, err := sessionMgr.GetSession(ctx, aimsid)
- require.NoError(t, err)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&f=json&mdir=1", nil)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- assert.Contains(t, rr.Body.String(), `"users":[]`)
- assert.Contains(t, rr.Body.String(), `"groups":[]`)
- }
- func TestPresenceHandler_GetPresence_TruncatesOversizedTargetList(t *testing.T) {
- ctx := context.Background()
- feedbagService := newMockFeedbagService(t)
- var queried int
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, body wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
- queried++
- return onlineUserInfoReply(body.ScreenName, 0), nil
- })
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- _, err := sessionMgr.GetSession(ctx, aimsid)
- require.NoError(t, err)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- query := "/presence/get?aimsid=" + aimsid + "&f=json"
- for i := range maxPresenceTargets + 8 {
- query += fmt.Sprintf("&t=user%d", i)
- }
- req, _ := http.NewRequest("GET", query, nil)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- assert.Contains(t, rr.Body.String(), `"statusCode":200`)
- assert.Equal(t, maxPresenceTargets, queried, "the list is cut to the cap, not refused")
- // A full search page has to fit: 20 profiles plus the searched-for UIN.
- assert.GreaterOrEqual(t, maxPresenceTargets, 21)
- }
- // searchPageTargets builds n distinct screen names, standing in for the hits of a
- // member-directory search page.
- func searchPageTargets(n int) []string {
- names := make([]string, 0, n)
- for i := range n {
- names = append(names, fmt.Sprintf("user%d", i))
- }
- return names
- }
- // newSetStatusDriver returns a function that drives setStatus and reports the
- // capability list that reached LocateService.SetInfo, or nil when SetInfo was
- // never called. The web session is built once, so consecutive calls see the
- // capabilities the earlier ones left on it. It starts out with the sign-on
- // capabilities, unless caps replaces them.
- func newSetStatusDriver(t *testing.T, caps ...[16]byte) func(query string) ([][16]byte, int) {
- t.Helper()
- instance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
- session, err := sessionMgr.GetSession(context.Background(), aimsid)
- require.NoError(t, err)
- if len(caps) > 0 {
- session.capabilities = caps
- }
- var gotCaps [][16]byte
- var called bool
- locate := newMockLocateService(t)
- locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).
- Run(func(_ context.Context, _ *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) {
- called = true
- b, ok := inBody.Bytes(wire.LocateTLVTagsInfoCapabilities)
- require.True(t, ok, "setStatus must always send a capabilities TLV")
- require.Zero(t, len(b)%16)
- gotCaps = userInfoCaps(wire.TLVUserInfo{
- TLVBlock: wire.TLVBlock{
- TLVList: wire.TLVList{wire.NewTLVBE(wire.OServiceUserInfoOscarCaps, b)},
- },
- })
- }).Return(nil).Maybe()
- oservice := newMockOServiceService(t)
- oservice.EXPECT().
- SetUserInfoFields(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- Return(nil).
- Maybe()
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- LocateService: locate,
- OServiceService: oservice,
- Logger: slog.Default(),
- }
- return func(query string) ([][16]byte, int) {
- gotCaps, called = nil, false
- req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+query, nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
- if !called {
- return nil, rr.Code
- }
- return gotCaps, rr.Code
- }
- }
- // A state change must not blank the status message: the client renders both from
- // the same payload.
- func TestPresenceHandler_SetState_PreservesStatusMessage(t *testing.T) {
- oscarInstance := state.NewSession().AddInstance()
- oscarInstance.Session().SetStatus(testStatusBART)
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- oservice := newMockOServiceService(t)
- oservice.EXPECT().
- SetUserInfoFields(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- Return(nil)
- locate := newMockLocateService(t)
- locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).Return(nil)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- LocateService: locate,
- OServiceService: oservice,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=online", nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
- require.Equal(t, http.StatusOK, rr.Code)
- assert.Contains(t, rr.Body.String(), `"statusMsg":"brb"`)
- }
- func TestPresenceHandler_SetStatus_StatusMessage(t *testing.T) {
- tests := []struct {
- name string
- // query is appended to the request, after aimsid.
- query string
- // wantStatusMsg is the text that must reach OSCAR, when wantSet is true.
- wantStatusMsg string
- wantSet bool
- }{
- {
- name: "a status message is sent as a BART status item",
- query: "&statusMsg=out+to+lunch",
- wantStatusMsg: "out to lunch",
- wantSet: true,
- },
- {
- // The client clears the status message by sending an empty one.
- name: "an empty status message clears it",
- query: "&statusMsg=",
- wantSet: true,
- },
- {
- name: "an absent status message parameter leaves it alone",
- query: "&mood=",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- var gotBody wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields
- oservice := newMockOServiceService(t)
- if tt.wantSet {
- oservice.EXPECT().
- SetUserInfoFields(mock.Anything, oscarInstance, wire.SNACFrame{}, mock.Anything).
- Run(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, inBody wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) {
- gotBody = inBody
- }).
- Return(nil)
- }
- locate := newMockLocateService(t)
- locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- LocateService: locate,
- OServiceService: oservice,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+tt.query, nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
- require.Equal(t, http.StatusOK, rr.Code)
- if !tt.wantSet {
- return
- }
- b, hasBART := gotBody.Bytes(wire.OServiceUserInfoBARTInfo)
- require.True(t, hasBART, "setStatus must send a BART TLV")
- var gotID wire.BARTID
- require.NoError(t, wire.UnmarshalBE(&gotID, bytes.NewReader(b)))
- assert.Equal(t, wire.BARTTypesStatusStr, gotID.Type)
- assert.Equal(t, wire.BARTFlagsData, gotID.Flags)
- gotStatusMsg, err := gotID.StatusText()
- require.NoError(t, err)
- assert.Equal(t, tt.wantStatusMsg, gotStatusMsg)
- })
- }
- }
- func TestPresenceHandler_SetStatus_StatusMessageError(t *testing.T) {
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- oservice := newMockOServiceService(t)
- oservice.EXPECT().
- SetUserInfoFields(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- Return(io.EOF)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- OServiceService: oservice,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+"&statusMsg=nope", nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusInternalServerError, rr.Code)
- assert.Contains(t, rr.Body.String(), "failed to set status")
- }
- func TestPresenceHandler_SetStatus_StatusMessageTooLong(t *testing.T) {
- // An overlong message would wrap the one-byte length prefix and corrupt the
- // user info block.
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- // No EXPECT: nothing may reach OSCAR.
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- OServiceService: newMockOServiceService(t),
- Logger: slog.Default(),
- }
- tooLong := strings.Repeat("a", 252)
- req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+"&statusMsg="+tooLong, nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusBadRequest, rr.Code)
- assert.Contains(t, rr.Body.String(), wire.ErrStatusTextSizeExceeded.Error())
- }
- func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
- tests := []struct {
- name string
- // query is appended to the request, after aimsid.
- query string
- // wantCaps is the capability list that must reach SetInfo. A nil value
- // means SetInfo must not be called at all.
- wantCaps [][16]byte
- // wantCode defaults to 200.
- wantCode int
- }{
- {
- name: "a known mood is advertised as its capability",
- query: "&mood=0icqmood6",
- wantCaps: [][16]byte{wire.CapICQCh2Extended, wire.CapUTF8Messages, wire.CapXStatusPlate},
- },
- {
- name: "a mood with only a placeholder capability still resolves",
- query: "&mood=0icqmood13",
- wantCaps: [][16]byte{wire.CapICQCh2Extended, wire.CapUTF8Messages, wire.CapMoodHavingFun},
- },
- {
- // The client sends mood= alongside every plain state change, so this
- // is the path back to a moodless online/away/invisible. Only the
- // mood is dropped.
- name: "an empty mood clears the capability",
- query: "&mood=",
- wantCaps: [][16]byte{wire.CapICQCh2Extended, wire.CapUTF8Messages},
- },
- {
- // A token the server cannot map is a client bug, not a reset: the
- // mood the user is actually showing is left alone.
- name: "an unrecognized mood is rejected",
- query: "&mood=0icqmood999",
- wantCode: http.StatusBadRequest,
- },
- {
- // A status-message-only update must leave the mood alone.
- name: "an absent mood parameter leaves capabilities untouched",
- query: "&statusMsg=hello",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotCaps, code := newSetStatusDriver(t)(tt.query)
- if tt.wantCode != 0 {
- assert.Equal(t, tt.wantCode, code)
- assert.Nil(t, gotCaps, "a rejected mood must not reach SetInfo")
- return
- }
- assert.Equal(t, http.StatusOK, code)
- assert.Equal(t, tt.wantCaps, gotCaps)
- })
- }
- }
- func TestPresenceHandler_SetStatus_MoodReplacesRatherThanAccumulates(t *testing.T) {
- setStatus := newSetStatusDriver(t)
- _, code := setStatus("&mood=0icqmood6")
- assert.Equal(t, http.StatusOK, code)
- gotCaps, code := setStatus("&mood=0icqmood4")
- assert.Equal(t, http.StatusOK, code)
- assert.Equal(t, [][16]byte{wire.CapICQCh2Extended, wire.CapUTF8Messages, wire.CapXStatusBeer}, gotCaps,
- "the previous mood must be dropped")
- }
- func TestPresenceHandler_SetStatus_PreservesNonMoodCaps(t *testing.T) {
- // The capability list is rewritten wholesale, so anything the session
- // advertises that is not a mood has to be carried over.
- setStatus := newSetStatusDriver(t, wire.CapChat, wire.CapXStatusBeer)
- gotCaps, code := setStatus("&mood=0icqmood6")
- assert.Equal(t, http.StatusOK, code)
- assert.Equal(t, [][16]byte{wire.CapChat, wire.CapXStatusPlate}, gotCaps)
- gotCaps, code = setStatus("&mood=")
- assert.Equal(t, http.StatusOK, code)
- assert.Equal(t, [][16]byte{wire.CapChat}, gotCaps, "clearing a mood must keep the other caps")
- }
- func TestPresenceHandler_SetStatus_SetInfoError(t *testing.T) {
- instance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
- locate := newMockLocateService(t)
- locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).Return(io.EOF)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- LocateService: locate,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+"&mood=0icqmood6", nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusInternalServerError, rr.Code)
- }
- // presence/get?t= serves what the view knows and only pays for a locate query
- // when the caller asks for something the view cannot carry.
- func TestPresenceHandler_GetPresence_TargetsFallBackToLocate(t *testing.T) {
- // getTargets drives presence/get for one target and returns the single user
- // the response carries.
- getTargets := func(t *testing.T, target, extraParams string, seed func(*Session), setupLocate func(*mockLocateService)) struct {
- AimID string `json:"aimId"`
- State string `json:"state"`
- AwayMsg string `json:"awayMsg"`
- StatusMsg string `json:"statusMsg"`
- } {
- t.Helper()
- feedbagService := newMockFeedbagService(t)
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
- locateService := newMockLocateService(t)
- setupLocate(locateService)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- sess, err := sessionMgr.GetSession(context.Background(), aimsid)
- require.NoError(t, err)
- seed(sess)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- IconSource: newTestIconSource(t),
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t="+target+extraParams, nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- require.Equal(t, http.StatusOK, rr.Code)
- var got struct {
- Response struct {
- Data struct {
- Users []struct {
- AimID string `json:"aimId"`
- State string `json:"state"`
- AwayMsg string `json:"awayMsg"`
- StatusMsg string `json:"statusMsg"`
- } `json:"users"`
- } `json:"data"`
- } `json:"response"`
- }
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
- require.Len(t, got.Response.Data.Users, 1)
- return got.Response.Data.Users[0]
- }
- noLocate := func(*mockLocateService) {}
- t.Run("an online buddy is served from the view", func(t *testing.T) {
- got := getTargets(t, "mikekelly", "", func(sess *Session) {
- buddyArrives(sess, bartBuddy("Mike Kelly", testStatusBART))
- }, noLocate)
- assert.Equal(t, "online", got.State)
- assert.Equal(t, "brb", got.StatusMsg)
- })
- // A target need not be a buddy, so nothing may be concluded from a miss.
- t.Run("a target the view has never seen is looked up", func(t *testing.T) {
- got := getTargets(t, "stranger", "", func(*Session) {}, func(ls *mockLocateService) {
- ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("stranger")).
- Return(onlineUserInfoReply("stranger", 0), nil).Once()
- })
- assert.Equal(t, "online", got.State)
- })
- // An away message is absent from presence broadcasts, so it costs a query.
- t.Run("an away buddy is looked up for their away message", func(t *testing.T) {
- awayReply := wire.TLVUserInfo{ScreenName: "awaybud"}
- awayReply.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
- got := getTargets(t, "awaybud", "", func(sess *Session) {
- buddyArrives(sess, awayReply)
- }, func(ls *mockLocateService) {
- ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("awaybud")).
- Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
- TLVUserInfo: awayReply,
- LocateInfo: wire.TLVRestBlock{TLVList: wire.TLVList{
- wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "out to lunch"),
- }},
- }}, nil).Once()
- })
- assert.Equal(t, "away", got.State)
- assert.Equal(t, "out to lunch", got.AwayMsg)
- })
- // Profile text is likewise only reachable through a locate reply.
- t.Run("profileMsg forces a lookup even for a cached buddy", func(t *testing.T) {
- got := getTargets(t, "mikekelly", "&profileMsg=1", func(sess *Session) {
- buddyArrives(sess, onlineBuddy("Mike Kelly"))
- }, func(ls *mockLocateService) {
- ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("mikekelly")).
- Return(onlineUserInfoReply("Mike Kelly", 0), nil).Once()
- })
- assert.Equal(t, "online", got.State)
- })
- }
- // presence/get?bl=1 walks the whole roster, so only an unavailable buddy may cost
- // a locate query. Mockery fails the test on any other call.
- func TestPresenceHandler_GetPresence_BuddyListQueriesOnlyAwayBuddies(t *testing.T) {
- items := []wire.FeedbagItem{
- {ItemID: 0, GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
- {ItemID: 0, GroupID: 10, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
- {ItemID: 101, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "onlinebud"},
- {ItemID: 102, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "awaybud"},
- {ItemID: 103, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "neverseen"},
- }
- feedbagService := newMockFeedbagService(t)
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("awaybud")).
- Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
- TLVUserInfo: wire.TLVUserInfo{ScreenName: "awaybud"},
- LocateInfo: wire.TLVRestBlock{TLVList: wire.TLVList{
- wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "out to lunch"),
- }},
- }}, nil).Once()
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- sess, err := sessionMgr.GetSession(context.Background(), aimsid)
- require.NoError(t, err)
- sess.FeedbagLoader = feedbagServiceLoader(feedbagService, oscarInstance)
- away := wire.TLVUserInfo{ScreenName: "awaybud"}
- away.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
- buddyArrives(sess, onlineBuddy("onlinebud"))
- buddyArrives(sess, away)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- IconSource: newTestIconSource(t),
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&bl=1", nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- require.Equal(t, http.StatusOK, rr.Code)
- var got struct {
- Response struct {
- Data struct {
- Groups []struct {
- Buddies []struct {
- AimID string `json:"aimId"`
- State string `json:"state"`
- AwayMsg string `json:"awayMsg"`
- } `json:"buddies"`
- } `json:"groups"`
- } `json:"data"`
- } `json:"response"`
- }
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
- require.Len(t, got.Response.Data.Groups, 1)
- states := map[string]string{}
- awayMsgs := map[string]string{}
- for _, b := range got.Response.Data.Groups[0].Buddies {
- states[b.AimID] = b.State
- awayMsgs[b.AimID] = b.AwayMsg
- }
- assert.Equal(t, "online", states["onlinebud"])
- assert.Equal(t, "away", states["awaybud"])
- // The away message is the one field a presence broadcast cannot carry.
- assert.Equal(t, "out to lunch", awayMsgs["awaybud"])
- // A buddy no arrival has been relayed for renders offline rather than
- // triggering a lookup.
- assert.Equal(t, "offline", states["neverseen"])
- }
- // presence/get?t= must not trust a cache hit it no longer holds. It branches on a
- // lookup and then renders the record, so the record has to travel with the branch:
- // looking it up a second time can miss after an eviction and report the user
- // offline instead of falling back to the locate query.
- func TestPresenceHandler_GetPresence_ForgottenTargetFallsBackToLocate(t *testing.T) {
- feedbagService := newMockFeedbagService(t)
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
- // The target is no longer watched, so only a live lookup knows their state.
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("mikekelly")).
- Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil).Once()
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- sess, err := sessionMgr.GetSession(context.Background(), aimsid)
- require.NoError(t, err)
- buddyArrives(sess, onlineBuddy("Mike Kelly"))
- sess.forgetBuddyPresence(state.NewIdentScreenName("mikekelly"))
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- IconSource: newTestIconSource(t),
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=mikekelly", nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- require.Equal(t, http.StatusOK, rr.Code)
- var got struct {
- Response struct {
- Data struct {
- Users []struct {
- AimID string `json:"aimId"`
- State string `json:"state"`
- } `json:"users"`
- } `json:"data"`
- } `json:"response"`
- }
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
- require.Len(t, got.Response.Data.Users, 1)
- // The stale "online" snapshot must not survive the eviction.
- assert.Equal(t, "offline", got.Response.Data.Users[0].State)
- }
- // An invisible user reads as offline, and an offline user publishes nothing.
- func TestPresenceHandler_GetPresence_InvisibleUserPublishesNothing(t *testing.T) {
- invisible := wire.TLVUserInfo{ScreenName: "sneaky"}
- invisible.Append(wire.NewTLVBE(wire.OServiceUserInfoStatus, wire.OServiceUserStatusInvisible))
- invisible.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testStatusBART}))
- feedbagService := newMockFeedbagService(t)
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("sneaky")).
- Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
- TLVUserInfo: invisible,
- LocateInfo: wire.TLVRestBlock{TLVList: wire.TLVList{
- wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "back later"),
- }},
- }}, nil).Once()
- iconRetriever := newMockBuddyIconRetriever(t)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- sess, err := sessionMgr.GetSession(context.Background(), aimsid)
- require.NoError(t, err)
- sess.BaseURL = "http://api.example.com"
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- IconSource: BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=sneaky", nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- require.Equal(t, http.StatusOK, rr.Code)
- var got struct {
- Response struct {
- Data struct {
- Users []struct {
- State string `json:"state"`
- BuddyIcon string `json:"buddyIcon"`
- StatusMsg string `json:"statusMsg"`
- AwayMsg string `json:"awayMsg"`
- } `json:"users"`
- } `json:"data"`
- } `json:"response"`
- }
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
- require.Len(t, got.Response.Data.Users, 1)
- user := got.Response.Data.Users[0]
- assert.Equal(t, "offline", user.State)
- assert.Empty(t, user.BuddyIcon)
- assert.Empty(t, user.StatusMsg)
- assert.Empty(t, user.AwayMsg)
- iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, mock.Anything)
- }
|