buddy_list_manager_test.go 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911
  1. package webapi
  2. import (
  3. "context"
  4. "errors"
  5. "log/slog"
  6. "testing"
  7. "github.com/stretchr/testify/assert"
  8. "github.com/stretchr/testify/mock"
  9. "github.com/stretchr/testify/require"
  10. "github.com/mk6i/open-oscar-server/state"
  11. "github.com/mk6i/open-oscar-server/wire"
  12. )
  13. func offlineWebAPIBuddy(aimID, displayID string) BuddyInfo {
  14. return BuddyInfo{
  15. AimID: aimID,
  16. DisplayID: displayID,
  17. State: "offline",
  18. UserType: "aim",
  19. Bot: false,
  20. }
  21. }
  22. // withAlias sets the viewer's private name for a buddy. It travels in friendly, not
  23. // displayId, which keeps carrying the buddy's own screen name.
  24. func withAlias(b BuddyInfo, alias string) BuddyInfo {
  25. b.Friendly = alias
  26. return b
  27. }
  28. func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
  29. ctx := context.Background()
  30. owner := state.NewIdentScreenName("listowner")
  31. tests := []struct {
  32. name string
  33. fb []wire.FeedbagItem
  34. fbErr error
  35. want []BuddyGroup
  36. wantErr string
  37. }{
  38. {
  39. name: "retrieve feedbag error",
  40. fbErr: errors.New("db unavailable"),
  41. wantErr: "failed to retrieve feedbag",
  42. },
  43. {
  44. name: "root group only yields no groups",
  45. fb: []wire.FeedbagItem{
  46. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, TLVLBlock: wire.TLVLBlock{}},
  47. },
  48. want: []BuddyGroup{},
  49. },
  50. {
  51. name: "empty buddylist yields no groups",
  52. fb: []wire.FeedbagItem{
  53. {
  54. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  55. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{})}},
  56. },
  57. },
  58. want: []BuddyGroup{},
  59. },
  60. {
  61. name: "single group with buddies",
  62. fb: []wire.FeedbagItem{
  63. {
  64. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  65. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  66. },
  67. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  68. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2})}}},
  69. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "user1", TLVLBlock: wire.TLVLBlock{}},
  70. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "user2", TLVLBlock: wire.TLVLBlock{}},
  71. },
  72. want: []BuddyGroup{
  73. {
  74. Name: "Buddies",
  75. ID: 100,
  76. Buddies: []BuddyInfo{
  77. offlineWebAPIBuddy("user1", "user1"),
  78. offlineWebAPIBuddy("user2", "user2"),
  79. },
  80. },
  81. },
  82. },
  83. {
  84. name: "deny permit and pdinfo items do not produce groups",
  85. fb: []wire.FeedbagItem{
  86. {
  87. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  88. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{})}},
  89. },
  90. {ClassID: wire.FeedbagClassIDDeny, Name: "blockeduser"},
  91. {ClassID: wire.FeedbagClassIDPermit, Name: "allowuser"},
  92. {
  93. ClassID: wire.FeedbagClassIdPdinfo,
  94. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesPdMode, uint8(3))}},
  95. },
  96. },
  97. want: []BuddyGroup{},
  98. },
  99. {
  100. name: "buddy with alias",
  101. fb: []wire.FeedbagItem{
  102. {
  103. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  104. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  105. },
  106. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  107. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  108. {
  109. ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "bob",
  110. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesAlias, "Bob Smith")}},
  111. },
  112. },
  113. want: []BuddyGroup{
  114. {
  115. Name: "Buddies",
  116. ID: 100,
  117. // The buddy is offline, so no locate reply supplies a display
  118. // name and displayId falls back to the normalized feedbag name.
  119. Buddies: []BuddyInfo{withAlias(offlineWebAPIBuddy("bob", "bob"), "Bob Smith")},
  120. },
  121. },
  122. },
  123. {
  124. name: "unnormalized feedbag buddy name still yields a normalized aimId",
  125. fb: []wire.FeedbagItem{
  126. {
  127. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  128. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  129. },
  130. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  131. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  132. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "Mike Kelly"},
  133. },
  134. want: []BuddyGroup{
  135. {
  136. Name: "Buddies",
  137. ID: 100,
  138. Buddies: []BuddyInfo{offlineWebAPIBuddy("mikekelly", "Mike Kelly")},
  139. },
  140. },
  141. },
  142. {
  143. name: "uin buddy is tagged icq",
  144. fb: []wire.FeedbagItem{
  145. {
  146. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  147. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  148. },
  149. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  150. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  151. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "100003"},
  152. },
  153. want: []BuddyGroup{
  154. {
  155. Name: "Buddies",
  156. ID: 100,
  157. Buddies: []BuddyInfo{{
  158. AimID: "100003",
  159. DisplayID: "100003",
  160. State: "offline",
  161. UserType: "icq",
  162. Service: "icq",
  163. }},
  164. },
  165. },
  166. },
  167. {
  168. name: "buddy with note still listed note not exposed in WebAPI",
  169. fb: []wire.FeedbagItem{
  170. {
  171. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  172. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  173. },
  174. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  175. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  176. {
  177. ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "alice",
  178. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesNote, "Friend from work")}},
  179. },
  180. },
  181. want: []BuddyGroup{
  182. {
  183. Name: "Buddies",
  184. ID: 100,
  185. Buddies: []BuddyInfo{offlineWebAPIBuddy("alice", "alice")},
  186. },
  187. },
  188. },
  189. {
  190. name: "multiple groups in root order",
  191. fb: []wire.FeedbagItem{
  192. {
  193. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  194. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100, 200})}},
  195. },
  196. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  197. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  198. {Name: "Family", GroupID: 200, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  199. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{2})}}},
  200. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "friend1", TLVLBlock: wire.TLVLBlock{}},
  201. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 200, Name: "mom", TLVLBlock: wire.TLVLBlock{}},
  202. },
  203. want: []BuddyGroup{
  204. {
  205. Name: "Buddies",
  206. ID: 100,
  207. Buddies: []BuddyInfo{offlineWebAPIBuddy("friend1", "friend1")},
  208. },
  209. {
  210. Name: "Family",
  211. ID: 200,
  212. Buddies: []BuddyInfo{offlineWebAPIBuddy("mom", "mom")},
  213. },
  214. },
  215. },
  216. {
  217. name: "buddy order follows group order TLV not feedbag slice order",
  218. fb: []wire.FeedbagItem{
  219. {
  220. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  221. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  222. },
  223. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  224. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{2, 1})}}},
  225. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "firstInSlice", TLVLBlock: wire.TLVLBlock{}},
  226. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "secondInSlice", TLVLBlock: wire.TLVLBlock{}},
  227. },
  228. want: []BuddyGroup{
  229. {
  230. Name: "Buddies",
  231. ID: 100,
  232. Buddies: []BuddyInfo{
  233. offlineWebAPIBuddy("secondinslice", "secondInSlice"),
  234. offlineWebAPIBuddy("firstinslice", "firstInSlice"),
  235. },
  236. },
  237. },
  238. },
  239. {
  240. name: "group order follows root order TLV not feedbag slice order",
  241. fb: []wire.FeedbagItem{
  242. {
  243. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  244. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{200, 100})}},
  245. },
  246. {Name: "Family", GroupID: 200, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  247. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{2})}}},
  248. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  249. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  250. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "inBuddies", TLVLBlock: wire.TLVLBlock{}},
  251. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 200, Name: "inFamily", TLVLBlock: wire.TLVLBlock{}},
  252. },
  253. want: []BuddyGroup{
  254. {
  255. Name: "Family",
  256. ID: 200,
  257. Buddies: []BuddyInfo{offlineWebAPIBuddy("infamily", "inFamily")},
  258. },
  259. {
  260. Name: "Buddies",
  261. ID: 100,
  262. Buddies: []BuddyInfo{offlineWebAPIBuddy("inbuddies", "inBuddies")},
  263. },
  264. },
  265. },
  266. {
  267. name: "unnamed group becomes Buddies",
  268. fb: []wire.FeedbagItem{
  269. {
  270. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  271. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  272. },
  273. {Name: "", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  274. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  275. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "solo", TLVLBlock: wire.TLVLBlock{}},
  276. },
  277. want: []BuddyGroup{
  278. {
  279. Name: "Buddies",
  280. ID: 100,
  281. Buddies: []BuddyInfo{offlineWebAPIBuddy("solo", "solo")},
  282. },
  283. },
  284. },
  285. {
  286. name: "group without order attribute lists buddies in feedbag order",
  287. fb: []wire.FeedbagItem{
  288. {
  289. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  290. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  291. },
  292. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup},
  293. {ItemID: 9, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "user9"},
  294. {ItemID: 3, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "user3"},
  295. },
  296. want: []BuddyGroup{
  297. {
  298. Name: "Buddies",
  299. ID: 100,
  300. Buddies: []BuddyInfo{
  301. offlineWebAPIBuddy("user9", "user9"),
  302. offlineWebAPIBuddy("user3", "user3"),
  303. },
  304. },
  305. },
  306. },
  307. {
  308. name: "buddy missing from group order follows listed buddies",
  309. fb: []wire.FeedbagItem{
  310. {
  311. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  312. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  313. },
  314. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  315. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{2})}}},
  316. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "unlisted"},
  317. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "listed"},
  318. },
  319. want: []BuddyGroup{
  320. {
  321. Name: "Buddies",
  322. ID: 100,
  323. Buddies: []BuddyInfo{
  324. offlineWebAPIBuddy("listed", "listed"),
  325. offlineWebAPIBuddy("unlisted", "unlisted"),
  326. },
  327. },
  328. },
  329. },
  330. {
  331. name: "group missing from root order follows listed groups",
  332. fb: []wire.FeedbagItem{
  333. {
  334. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  335. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{200})}},
  336. },
  337. {Name: "Unlisted", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup},
  338. {Name: "Listed", GroupID: 200, ItemID: 0, ClassID: wire.FeedbagClassIdGroup},
  339. },
  340. want: []BuddyGroup{
  341. {Name: "Listed", ID: 200, Buddies: []BuddyInfo{}},
  342. {Name: "Unlisted", ID: 100, Buddies: []BuddyInfo{}},
  343. },
  344. },
  345. {
  346. name: "groups follow feedbag position when root group has no order attribute",
  347. fb: []wire.FeedbagItem{
  348. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup},
  349. {Name: "Family", GroupID: 200, ItemID: 0, ClassID: wire.FeedbagClassIdGroup},
  350. {Name: "Friends", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup},
  351. },
  352. want: []BuddyGroup{
  353. {Name: "Family", ID: 200, Buddies: []BuddyInfo{}},
  354. {Name: "Friends", ID: 100, Buddies: []BuddyInfo{}},
  355. },
  356. },
  357. {
  358. name: "orphan buddy with root order is left out",
  359. fb: []wire.FeedbagItem{
  360. {
  361. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  362. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  363. },
  364. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  365. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  366. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "user1"},
  367. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 300, Name: "orphan"},
  368. },
  369. want: []BuddyGroup{
  370. {
  371. Name: "Buddies",
  372. ID: 100,
  373. Buddies: []BuddyInfo{offlineWebAPIBuddy("user1", "user1")},
  374. },
  375. },
  376. },
  377. {
  378. name: "orphan buddy without root order is left out",
  379. fb: []wire.FeedbagItem{
  380. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 300, Name: "orphan"},
  381. },
  382. want: []BuddyGroup{},
  383. },
  384. }
  385. for _, tt := range tests {
  386. t.Run(tt.name, func(t *testing.T) {
  387. fs := newMockFeedbagService(t)
  388. // No buddy arrives, so every buddy resolves to offline, keeping the
  389. // focus on feedbag -> group conversion.
  390. if tt.fbErr != nil {
  391. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(wire.SNACMessage{}, tt.fbErr).Once()
  392. } else {
  393. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  394. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: tt.fb}}, nil,
  395. ).Once()
  396. }
  397. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  398. sess := &Session{
  399. ScreenName: state.DisplayScreenName(owner.String()),
  400. OSCARSession: state.NewSession().AddInstance(),
  401. logger: slog.Default(),
  402. }
  403. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  404. got, err := m.GetBuddyListForUser(ctx, sess)
  405. if tt.wantErr != "" {
  406. assert.ErrorContains(t, err, tt.wantErr)
  407. assert.Nil(t, got)
  408. return
  409. }
  410. assert.NoError(t, err)
  411. assert.Equal(t, tt.want, got)
  412. })
  413. }
  414. }
  415. func TestBuddyListManager_GetBuddyListForUser_DisplayIDFromPresenceView(t *testing.T) {
  416. // Feedbag buddy names are stored normalized, so an online buddy's display
  417. // name can only come from the user info their arrival carried.
  418. ctx := context.Background()
  419. fb := []wire.FeedbagItem{
  420. {
  421. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  422. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  423. },
  424. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  425. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  426. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"},
  427. }
  428. fs := newMockFeedbagService(t)
  429. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  430. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
  431. ).Once()
  432. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  433. sess := &Session{
  434. ScreenName: state.DisplayScreenName("listowner"),
  435. OSCARSession: state.NewSession().AddInstance(),
  436. logger: slog.Default(),
  437. }
  438. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  439. buddyArrives(sess, onlineBuddy("Mike Kelly"))
  440. got, err := m.GetBuddyListForUser(ctx, sess)
  441. require.NoError(t, err)
  442. require.Len(t, got, 1)
  443. require.Len(t, got[0].Buddies, 1)
  444. assert.Equal(t, "mikekelly", got[0].Buddies[0].AimID)
  445. assert.Equal(t, "Mike Kelly", got[0].Buddies[0].DisplayID)
  446. assert.Equal(t, "online", got[0].Buddies[0].State)
  447. }
  448. // A departure keeps the display name the buddy last arrived with, rather than
  449. // falling back to the normalized feedbag name.
  450. func TestBuddyListManager_GetBuddyListForUser_DepartureKeepsDisplayID(t *testing.T) {
  451. ctx := context.Background()
  452. fb := []wire.FeedbagItem{
  453. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  454. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  455. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  456. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  457. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"},
  458. }
  459. fs := newMockFeedbagService(t)
  460. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  461. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
  462. ).Once()
  463. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  464. sess := &Session{
  465. ScreenName: state.DisplayScreenName("listowner"),
  466. OSCARSession: state.NewSession().AddInstance(),
  467. BaseURL: "http://api.example.com",
  468. logger: slog.Default(),
  469. }
  470. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  471. buddyArrives(sess, bartBuddy("Mike Kelly", testIconBART, testStatusBART))
  472. buddyDeparts(sess, "Mike Kelly")
  473. got, err := m.GetBuddyListForUser(ctx, sess)
  474. require.NoError(t, err)
  475. require.Len(t, got[0].Buddies, 1)
  476. buddy := got[0].Buddies[0]
  477. assert.Equal(t, "offline", buddy.State)
  478. assert.Equal(t, "Mike Kelly", buddy.DisplayID)
  479. // An offline buddy publishes neither their icon nor their status message.
  480. assert.Empty(t, buddy.BuddyIcon)
  481. assert.Empty(t, buddy.StatusMsg)
  482. }
  483. // Icons are published only for online buddies: one with an icon gets a
  484. // content-addressed URL, one without gets the placeholder URL, and an offline
  485. // buddy gets none, so neither their icon nor its hash leaks. The hash rides in on
  486. // the arrival, so no metadata lookup happens.
  487. func TestBuddyListManager_GetBuddyListForUser_PublishesBuddyIcons(t *testing.T) {
  488. ctx := context.Background()
  489. fb := []wire.FeedbagItem{
  490. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  491. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  492. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  493. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2, 3})}}},
  494. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "onlineicon"},
  495. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "offlinebud"},
  496. {ItemID: 3, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "onlinenoicon"},
  497. }
  498. fs := newMockFeedbagService(t)
  499. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  500. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
  501. ).Once()
  502. iconRetriever := newMockBuddyIconRetriever(t)
  503. m := NewBuddyListManager(fs, newMockLocateService(t), BuddyIconSource{
  504. IconRetriever: iconRetriever,
  505. Logger: slog.Default(),
  506. }, slog.Default())
  507. sess := &Session{
  508. ScreenName: state.DisplayScreenName("listowner"),
  509. OSCARSession: state.NewSession().AddInstance(),
  510. BaseURL: "http://api.example.com",
  511. logger: slog.Default(),
  512. }
  513. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  514. buddyArrives(sess, bartBuddy("onlineicon", wire.BARTID{
  515. Type: wire.BARTTypesBuddyIcon,
  516. BARTInfo: wire.BARTInfo{Hash: []byte{0xab, 0xcd}},
  517. }))
  518. buddyArrives(sess, onlineBuddy("onlinenoicon"))
  519. got, err := m.GetBuddyListForUser(ctx, sess)
  520. require.NoError(t, err)
  521. require.Len(t, got, 1)
  522. require.Len(t, got[0].Buddies, 3)
  523. // onlineicon: content-addressed URL carrying the icon hash.
  524. assert.Equal(t, "online", got[0].Buddies[0].State)
  525. assert.Equal(t,
  526. "http://api.example.com/expressions/get?t=onlineicon&type=buddyIcon&bartId=abcd",
  527. got[0].Buddies[0].BuddyIcon)
  528. // offlinebud: never arrived, so no icon.
  529. assert.Equal(t, "offline", got[0].Buddies[1].State)
  530. assert.Empty(t, got[0].Buddies[1].BuddyIcon)
  531. // onlinenoicon: hash-less placeholder URL so a cleared icon still propagates.
  532. assert.Equal(t, "online", got[0].Buddies[2].State)
  533. assert.Equal(t,
  534. "http://api.example.com/expressions/get?t=onlinenoicon&type=buddyIcon",
  535. got[0].Buddies[2].BuddyIcon)
  536. // The arrivals carried every hash, so the roster costs no metadata lookups.
  537. iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, mock.Anything)
  538. }
  539. // The roster is where a client first reads a buddy's status message.
  540. func TestBuddyListManager_GetBuddyListForUser_PublishesStatusMessages(t *testing.T) {
  541. ctx := context.Background()
  542. fb := []wire.FeedbagItem{
  543. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  544. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  545. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  546. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2})}}},
  547. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "hasstatus"},
  548. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "nostatus"},
  549. }
  550. fs := newMockFeedbagService(t)
  551. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  552. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
  553. ).Once()
  554. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  555. sess := &Session{
  556. ScreenName: state.DisplayScreenName("listowner"),
  557. OSCARSession: state.NewSession().AddInstance(),
  558. BaseURL: "http://api.example.com",
  559. logger: slog.Default(),
  560. }
  561. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  562. buddyArrives(sess, bartBuddy("hasstatus", testStatusBART))
  563. buddyArrives(sess, onlineBuddy("nostatus"))
  564. got, err := m.GetBuddyListForUser(ctx, sess)
  565. require.NoError(t, err)
  566. require.Len(t, got, 1)
  567. require.Len(t, got[0].Buddies, 2)
  568. assert.Equal(t, "brb", got[0].Buddies[0].StatusMsg)
  569. assert.Empty(t, got[0].Buddies[1].StatusMsg)
  570. }
  571. // The roster is rebuilt on every feedbag change, so only an unavailable buddy may
  572. // cost a locate query. Mockery fails the test on any other call.
  573. func TestBuddyListManager_GetBuddyListForUser_QueriesOnlyAwayBuddies(t *testing.T) {
  574. ctx := context.Background()
  575. fb := []wire.FeedbagItem{
  576. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  577. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  578. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  579. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2, 3})}}},
  580. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "onlinebud"},
  581. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "awaybud"},
  582. {ItemID: 3, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "neverseen"},
  583. }
  584. fs := newMockFeedbagService(t)
  585. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  586. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
  587. ).Once()
  588. ls := newMockLocateService(t)
  589. // Only the away buddy earns a query.
  590. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything,
  591. mock.MatchedBy(func(q wire.SNAC_0x02_0x05_LocateUserInfoQuery) bool {
  592. return q.ScreenName == "awaybud"
  593. })).
  594. Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
  595. TLVUserInfo: wire.TLVUserInfo{ScreenName: "awaybud"},
  596. LocateInfo: wire.TLVRestBlock{TLVList: wire.TLVList{
  597. wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "out to lunch"),
  598. }},
  599. }}, nil).Once()
  600. m := NewBuddyListManager(fs, ls, newTestIconSource(t), slog.Default())
  601. sess := &Session{
  602. ScreenName: state.DisplayScreenName("listowner"),
  603. OSCARSession: state.NewSession().AddInstance(),
  604. logger: slog.Default(),
  605. }
  606. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  607. away := wire.TLVUserInfo{ScreenName: "awaybud"}
  608. away.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
  609. buddyArrives(sess, onlineBuddy("onlinebud"))
  610. buddyArrives(sess, away)
  611. got, err := m.GetBuddyListForUser(ctx, sess)
  612. require.NoError(t, err)
  613. require.Len(t, got[0].Buddies, 3)
  614. assert.Equal(t, "online", got[0].Buddies[0].State)
  615. assert.Equal(t, "away", got[0].Buddies[1].State)
  616. // The away message is the one field a presence broadcast cannot carry.
  617. assert.Equal(t, "out to lunch", got[0].Buddies[1].AwayMsg)
  618. // A buddy no arrival has been relayed for renders offline rather than
  619. // triggering a lookup.
  620. assert.Equal(t, "offline", got[0].Buddies[2].State)
  621. }
  622. // The feedbag service relays a session's own writes only to the owner's other
  623. // instances, so renaming a buddy from the web client produces no SNAC for that
  624. // session. Without an explicit invalidation, its cached aliases would keep serving
  625. // the old name and the next presence or IM event would rename the buddy back.
  626. func TestBuddyListManager_SetBuddyAttributeInFeedbag_InvalidatesAliasCache(t *testing.T) {
  627. ctx := context.Background()
  628. feedbag := func(alias string) []wire.FeedbagItem {
  629. buddy := wire.FeedbagItem{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"}
  630. buddy.TLVLBlock = wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesAlias, alias)}}
  631. return []wire.FeedbagItem{
  632. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  633. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  634. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  635. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  636. buddy,
  637. }
  638. }
  639. fs := newMockFeedbagService(t)
  640. // Query 1: the alias cache loads. Query 2: SetBuddyAttributeInFeedbag reads the
  641. // feedbag it is about to rewrite. Query 3: the cache reloads post-invalidation,
  642. // now seeing the stored rename.
  643. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  644. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: feedbag("MICHAELKELLY")}}, nil).Twice()
  645. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  646. Return(&wire.SNACMessage{}, nil).Once()
  647. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  648. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: feedbag("MIKE")}}, nil).Once()
  649. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  650. sess := &Session{
  651. ScreenName: state.DisplayScreenName("listowner"),
  652. OSCARSession: state.NewSession().AddInstance(),
  653. }
  654. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  655. require.Equal(t, "MICHAELKELLY", sess.Aliases(ctx)["mikekelly"])
  656. resultCode, err := m.SetBuddyAttributeInFeedbag(ctx, sess, "mikekelly", "MIKE")
  657. require.NoError(t, err)
  658. require.Equal(t, "success", resultCode)
  659. assert.Equal(t, "MIKE", sess.Aliases(ctx)["mikekelly"])
  660. }
  661. func TestFeedbagGroupMatchesRequested(t *testing.T) {
  662. assert.True(t, feedbagGroupMatchesRequested("Buddies", "Buddies"))
  663. assert.True(t, feedbagGroupMatchesRequested("", "Buddies"))
  664. assert.True(t, feedbagGroupMatchesRequested(" ", "Buddies"))
  665. assert.True(t, feedbagGroupMatchesRequested("Friends", "friends"))
  666. assert.False(t, feedbagGroupMatchesRequested("", "Friends"))
  667. }
  668. func TestStoredGroupNameForRequest(t *testing.T) {
  669. items := []wire.FeedbagItem{
  670. {ItemID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "", GroupID: 1},
  671. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "jon", GroupID: 1},
  672. }
  673. st, ok := storedGroupNameForRequest(items, "Buddies")
  674. assert.True(t, ok)
  675. assert.Equal(t, "", st)
  676. items2 := []wire.FeedbagItem{
  677. {ItemID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 2},
  678. }
  679. st2, ok2 := storedGroupNameForRequest(items2, "Friends")
  680. assert.True(t, ok2)
  681. assert.Equal(t, "Friends", st2)
  682. }
  683. // Removing a buddy stops any further presence SNAC for them, so their cached
  684. // presence is dropped. A buddy listed in more than one group is still watched
  685. // after leaving one of them, and keeps their entry.
  686. func TestBuddyListManager_RemoveBuddyFromFeedbag_ForgetsPresence(t *testing.T) {
  687. ctx := context.Background()
  688. // feedbag places mikekelly in every group named.
  689. feedbag := func(groups ...string) []wire.FeedbagItem {
  690. items := []wire.FeedbagItem{
  691. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  692. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100, 200})}}},
  693. }
  694. for i, group := range groups {
  695. gid := uint16(100 * (i + 1))
  696. itemID := uint16(i + 1)
  697. items = append(items,
  698. wire.FeedbagItem{Name: group, GroupID: gid, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  699. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{itemID})}}},
  700. wire.FeedbagItem{ItemID: itemID, ClassID: wire.FeedbagClassIdBuddy, GroupID: gid, Name: "mikekelly"},
  701. )
  702. }
  703. return items
  704. }
  705. tests := []struct {
  706. name string
  707. fb []wire.FeedbagItem
  708. group string
  709. allGroups bool
  710. wantForgot bool
  711. description string
  712. }{
  713. {
  714. name: "sole listing forgets the buddy",
  715. fb: feedbag("Buddies"),
  716. group: "Buddies",
  717. wantForgot: true,
  718. },
  719. {
  720. name: "removing from all groups forgets the buddy",
  721. fb: feedbag("Buddies", "Work"),
  722. allGroups: true,
  723. wantForgot: true,
  724. },
  725. {
  726. // Still on the list via the other group, so still watched.
  727. name: "removal from one of two groups keeps the buddy",
  728. fb: feedbag("Buddies", "Work"),
  729. group: "Buddies",
  730. wantForgot: false,
  731. },
  732. }
  733. for _, tt := range tests {
  734. t.Run(tt.name, func(t *testing.T) {
  735. fs := newMockFeedbagService(t)
  736. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  737. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: tt.fb}}, nil,
  738. ).Maybe()
  739. fs.EXPECT().DeleteItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  740. Return(&wire.SNACMessage{}, nil).Once()
  741. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  742. Return(&wire.SNACMessage{}, nil).Maybe()
  743. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  744. sess := &Session{
  745. ScreenName: state.DisplayScreenName("listowner"),
  746. OSCARSession: state.NewSession().AddInstance(),
  747. logger: slog.Default(),
  748. }
  749. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  750. buddyArrives(sess, onlineBuddy("Mike Kelly"))
  751. resultCode, err := m.RemoveBuddyFromFeedbag(ctx, sess, "mikekelly", tt.group, tt.allGroups)
  752. require.NoError(t, err)
  753. require.Equal(t, "success", resultCode)
  754. _, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
  755. assert.Equal(t, !tt.wantForgot, ok)
  756. })
  757. }
  758. }
  759. // Deleting a group deletes the buddies in it.
  760. func TestBuddyListManager_RemoveGroupFromFeedbag_ForgetsPresence(t *testing.T) {
  761. ctx := context.Background()
  762. fb := []wire.FeedbagItem{
  763. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  764. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  765. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  766. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  767. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"},
  768. }
  769. fs := newMockFeedbagService(t)
  770. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  771. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
  772. ).Maybe()
  773. fs.EXPECT().DeleteItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  774. Return(&wire.SNACMessage{}, nil).Once()
  775. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  776. Return(&wire.SNACMessage{}, nil).Maybe()
  777. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  778. sess := &Session{
  779. ScreenName: state.DisplayScreenName("listowner"),
  780. OSCARSession: state.NewSession().AddInstance(),
  781. logger: slog.Default(),
  782. }
  783. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  784. buddyArrives(sess, onlineBuddy("Mike Kelly"))
  785. resultCode, err := m.RemoveGroupFromFeedbag(ctx, sess, "Buddies")
  786. require.NoError(t, err)
  787. require.Equal(t, "success", resultCode)
  788. _, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
  789. assert.False(t, ok)
  790. }
  791. // A method that rewrites the feedbag must re-read it even when the cache is warm:
  792. // it computes a pending diff from what it reads.
  793. func TestBuddyListManager_MutationsReReadTheFeedbag(t *testing.T) {
  794. ctx := context.Background()
  795. fb := []wire.FeedbagItem{
  796. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  797. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  798. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  799. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  800. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"},
  801. }
  802. var queries int
  803. fs := newMockFeedbagService(t)
  804. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  805. RunAndReturn(func(context.Context, *state.SessionInstance, wire.SNACFrame) (wire.SNACMessage, error) {
  806. queries++
  807. return wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil
  808. })
  809. fs.EXPECT().DeleteItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  810. Return(&wire.SNACMessage{}, nil).Once()
  811. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  812. Return(&wire.SNACMessage{}, nil).Maybe()
  813. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  814. sess := &Session{
  815. ScreenName: state.DisplayScreenName("listowner"),
  816. OSCARSession: state.NewSession().AddInstance(),
  817. logger: slog.Default(),
  818. }
  819. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  820. _, err := m.GetBuddyListForUser(ctx, sess)
  821. require.NoError(t, err)
  822. require.Equal(t, 1, queries)
  823. _, err = m.GetBuddyListForUser(ctx, sess)
  824. require.NoError(t, err)
  825. require.Equal(t, 1, queries, "a warm cache serves the roster without a read")
  826. _, err = m.RemoveBuddyFromFeedbag(ctx, sess, "mikekelly", "Buddies", false)
  827. require.NoError(t, err)
  828. assert.Equal(t, 2, queries, "the mutation re-reads rather than trusting the cache")
  829. _, err = m.GetBuddyListForUser(ctx, sess)
  830. require.NoError(t, err)
  831. assert.Equal(t, 3, queries, "the write invalidated the cache")
  832. }