Quellcode durchsuchen

increase test coverage on Feedbag handler

Mike vor 2 Jahren
Ursprung
Commit
e11898eaf7
7 geänderte Dateien mit 1158 neuen und 350 gelöschten Zeilen
  1. 7 22
      handler/buddy.go
  2. 93 42
      handler/feedbag.go
  3. 985 270
      handler/feedbag_test.go
  4. 6 2
      handler/oservice.go
  5. 5 14
      handler/oservice_test.go
  6. 48 0
      handler/test_helpers.go
  7. 14 0
      oscar/snacs.go

+ 7 - 22
handler/buddy.go

@@ -77,35 +77,20 @@ func broadcastDeparture(ctx context.Context, sess *state.Session, messageRelayer
 	return nil
 }
 
-func unicastArrival(ctx context.Context, srcScreenName, destScreenName string, messageRelayer MessageRelayer) {
-	sess := messageRelayer.RetrieveByScreenName(srcScreenName)
-	switch {
-	case sess == nil:
-		fallthrough
-	case sess.Invisible(): // don't tell user this buddy is online
-		return
-	}
-	messageRelayer.SendToScreenName(ctx, destScreenName, oscar.SNACMessage{
+func unicastArrival(ctx context.Context, from *state.Session, to *state.Session, messageRelayer MessageRelayer) {
+	messageRelayer.SendToScreenName(ctx, to.ScreenName(), oscar.SNACMessage{
 		Frame: oscar.SNACFrame{
 			FoodGroup: oscar.Buddy,
 			SubGroup:  oscar.BuddyArrived,
 		},
 		Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
-			TLVUserInfo: sess.TLVUserInfo(),
+			TLVUserInfo: from.TLVUserInfo(),
 		},
 	})
 }
 
-func unicastDeparture(ctx context.Context, srcScreenName, destScreenName string, messageRelayer MessageRelayer) {
-	sess := messageRelayer.RetrieveByScreenName(srcScreenName)
-	switch {
-	case sess == nil:
-		fallthrough
-	case sess.Invisible(): // don't tell user this buddy is online
-		return
-	}
-
-	messageRelayer.SendToScreenName(ctx, destScreenName, oscar.SNACMessage{
+func unicastDeparture(ctx context.Context, from *state.Session, to *state.Session, messageRelayer MessageRelayer) {
+	messageRelayer.SendToScreenName(ctx, to.ScreenName(), oscar.SNACMessage{
 		Frame: oscar.SNACFrame{
 			FoodGroup: oscar.Buddy,
 			SubGroup:  oscar.BuddyDeparted,
@@ -114,8 +99,8 @@ func unicastDeparture(ctx context.Context, srcScreenName, destScreenName string,
 			TLVUserInfo: oscar.TLVUserInfo{
 				// don't include the TLV block, otherwise the AIM client fails
 				// to process the block event
-				ScreenName:   sess.ScreenName(),
-				WarningLevel: sess.Warning(),
+				ScreenName:   from.ScreenName(),
+				WarningLevel: from.Warning(),
 			},
 		},
 	})

+ 93 - 42
handler/feedbag.go

@@ -8,15 +8,23 @@ import (
 	"github.com/mkaminski/goaim/state"
 )
 
-func NewFeedbagService(messageRelayer MessageRelayer, feedbagManager FeedbagManager) *FeedbagService {
-	return &FeedbagService{messageRelayer: messageRelayer, feedbagManager: feedbagManager}
+// NewFeedbagService creates a new instance of FeedbagService.
+func NewFeedbagService(messageRelayer MessageRelayer, feedbagManager FeedbagManager) FeedbagService {
+	return FeedbagService{
+		messageRelayer: messageRelayer,
+		feedbagManager: feedbagManager,
+	}
 }
 
+// FeedbagService provides handlers for the Feedbag food group.
 type FeedbagService struct {
 	messageRelayer MessageRelayer
 	feedbagManager FeedbagManager
 }
 
+// RightsQueryHandler returns SNAC oscar.FeedbagRightsReply, which contains
+// Feedbag food group settings for the current user. The values within the SNAC
+// are not well understood but seem to make the AIM client happy.
 func (s FeedbagService) RightsQueryHandler(_ context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
 	return oscar.SNACMessage{
 		Frame: oscar.SNACFrame{
@@ -27,45 +35,47 @@ func (s FeedbagService) RightsQueryHandler(_ context.Context, inFrame oscar.SNAC
 		Body: oscar.SNAC_0x13_0x03_FeedbagRightsReply{
 			TLVRestBlock: oscar.TLVRestBlock{
 				TLVList: oscar.TLVList{
-					oscar.NewTLV(0x03, uint16(200)),
-					oscar.NewTLV(0x04, []uint16{
-						0x3D,
-						0x3D,
-						0x64,
-						0x64,
-						0x01,
-						0x01,
-						0x32,
-						0x00,
-						0x00,
-						0x03,
-						0x00,
-						0x00,
-						0x00,
-						0x80,
-						0xFF,
-						0x14,
-						0xC8,
-						0x01,
-						0x00,
-						0x01,
-						0x00,
+					oscar.NewTLV(oscar.FeedbagRightsMaxItemAttrs, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsMaxItemsByClass, []uint16{
+						0x3D, // max num of contacts
+						0x3D, // max num of groups
+						0x64, // max visible contacts
+						0x64, // max invisible contacts
+						0x01, // max vis/invis bitmasks
+						0x01, // max presense info fields
+						0x32, // limit for item type 06
+						0x00, // limit for item type 07
+						0x00, // limit for item type 08
+						0x03, // limit for item type 09
+						0x00, // limit for item type 0a
+						0x00, // limit for item type 0b
+						0x00, // limit for item type 0c
+						0x80, // limit for item type 0d
+						0xFF, // max ignore list entries
+						0x14, // limit for item type 0f
+						0xC8, // limit for item 10
+						0x01, // limit for item 11
+						0x00, // limit for item 12
+						0x01, // limit for item 13
+						0x00, // limit for item 14
 					}),
-					oscar.NewTLV(0x05, uint16(200)),
-					oscar.NewTLV(0x06, uint16(200)),
-					oscar.NewTLV(0x07, uint16(200)),
-					oscar.NewTLV(0x08, uint16(200)),
-					oscar.NewTLV(0x09, uint16(200)),
-					oscar.NewTLV(0x0A, uint16(200)),
-					oscar.NewTLV(0x0C, uint16(200)),
-					oscar.NewTLV(0x0D, uint16(200)),
-					oscar.NewTLV(0x0E, uint16(100)),
+					oscar.NewTLV(oscar.FeedbagRightsMaxClientItems, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsMaxItemNameLen, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsMaxRecentBuddies, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsInteractionBuddies, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsInteractionHalfLife, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsInteractionMaxScore, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsMaxBuddiesPerGroup, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsMaxMegaBots, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsMaxSmartGroups, uint16(100)),
 				},
 			},
 		},
 	}
 }
 
+// QueryHandler fetches the user's feedbag (aka buddy list). It returns
+// oscar.FeedbagReply, which contains feedbag entries.
 func (s FeedbagService) QueryHandler(_ context.Context, sess *state.Session, inFrame oscar.SNACFrame) (oscar.SNACMessage, error) {
 	fb, err := s.feedbagManager.Retrieve(sess.ScreenName())
 	if err != nil {
@@ -95,6 +105,10 @@ func (s FeedbagService) QueryHandler(_ context.Context, sess *state.Session, inF
 	}, nil
 }
 
+// QueryIfModifiedHandler fetches the user's feedbag (aka buddy list). It
+// returns oscar.FeedbagReplyNotModified if the feedbag was last modified
+// before inBody.LastUpdate, else return oscar.FeedbagReply, which contains
+// feedbag entries.
 func (s FeedbagService) QueryIfModifiedHandler(_ context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x05_FeedbagQueryIfModified) (oscar.SNACMessage, error) {
 	fb, err := s.feedbagManager.Retrieve(sess.ScreenName())
 	if err != nil {
@@ -137,6 +151,11 @@ func (s FeedbagService) QueryIfModifiedHandler(_ context.Context, sess *state.Se
 	}, nil
 }
 
+// InsertItemHandler adds items to the user's feedbag (aka buddy list). Sends
+// user buddy arrival notifications for each online & visible buddy added to
+// the feedbag. Sends a buddy departure notification to blocked buddies if
+// current user is visible. It returns oscar.FeedbagStatus, which contains
+// insert confirmation.
 func (s FeedbagService) InsertItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x08_FeedbagInsertItem) (oscar.SNACMessage, error) {
 	for _, item := range inBody.Items {
 		// don't let users block themselves, it causes the AIM client to go
@@ -162,12 +181,21 @@ func (s FeedbagService) InsertItemHandler(ctx context.Context, sess *state.Sessi
 	for _, item := range inBody.Items {
 		switch item.ClassID {
 		case oscar.FeedbagClassIdBuddy, oscar.FeedbagClassIDPermit: // add new buddy
-			unicastArrival(ctx, item.Name, sess.ScreenName(), s.messageRelayer)
+			buddy := s.messageRelayer.RetrieveByScreenName(item.Name)
+			if buddy == nil || buddy.Invisible() {
+				continue
+			}
+			unicastArrival(ctx, buddy, sess, s.messageRelayer)
 		case oscar.FeedbagClassIDDeny: // block buddy
-			// notify this user that buddy is offline
-			unicastDeparture(ctx, item.Name, sess.ScreenName(), s.messageRelayer)
-			// notify former buddy that this user is offline
-			unicastDeparture(ctx, sess.ScreenName(), item.Name, s.messageRelayer)
+			if sess.Invisible() {
+				continue // user's offline, don't send departure notification
+			}
+			blockedSess := s.messageRelayer.RetrieveByScreenName(item.Name)
+			if blockedSess == nil {
+				continue // blocked buddy is offline, nothing to do here
+			}
+			// alert blocked buddy that current user is offline
+			unicastDeparture(ctx, sess, blockedSess, s.messageRelayer)
 		}
 	}
 
@@ -186,6 +214,10 @@ func (s FeedbagService) InsertItemHandler(ctx context.Context, sess *state.Sessi
 	}, nil
 }
 
+// UpdateItemHandler updates items in the user's feedbag (aka buddy list).
+// Sends user buddy arrival notifications for each online & visible buddy added
+// to the feedbag. It returns oscar.FeedbagStatus, which contains update
+// confirmation.
 func (s FeedbagService) UpdateItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x09_FeedbagUpdateItem) (oscar.SNACMessage, error) {
 	if err := s.feedbagManager.Upsert(sess.ScreenName(), inBody.Items); err != nil {
 		return oscar.SNACMessage{}, nil
@@ -194,7 +226,11 @@ func (s FeedbagService) UpdateItemHandler(ctx context.Context, sess *state.Sessi
 	for _, item := range inBody.Items {
 		switch item.ClassID {
 		case oscar.FeedbagClassIdBuddy, oscar.FeedbagClassIDPermit:
-			unicastArrival(ctx, item.Name, sess.ScreenName(), s.messageRelayer)
+			buddy := s.messageRelayer.RetrieveByScreenName(item.Name)
+			if buddy == nil || buddy.Invisible() {
+				continue
+			}
+			unicastArrival(ctx, buddy, sess, s.messageRelayer)
 		}
 	}
 
@@ -213,6 +249,11 @@ func (s FeedbagService) UpdateItemHandler(ctx context.Context, sess *state.Sessi
 	}, nil
 }
 
+// DeleteItemHandler removes items from feedbag (aka buddy list). Sends user
+// buddy arrival notifications for each online & visible buddy added to
+// the feedbag. Sends buddy arrival notifications to each unblocked buddy if
+// current user is visible. It returns oscar.FeedbagStatus, which contains update
+// confirmation.
 func (s FeedbagService) DeleteItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x0A_FeedbagDeleteItem) (oscar.SNACMessage, error) {
 	if err := s.feedbagManager.Delete(sess.ScreenName(), inBody.Items); err != nil {
 		return oscar.SNACMessage{}, err
@@ -220,8 +261,18 @@ func (s FeedbagService) DeleteItemHandler(ctx context.Context, sess *state.Sessi
 
 	for _, item := range inBody.Items {
 		if item.ClassID == oscar.FeedbagClassIDDeny {
-			unicastArrival(ctx, item.Name, sess.ScreenName(), s.messageRelayer)
-			unicastArrival(ctx, sess.ScreenName(), item.Name, s.messageRelayer)
+			unblockedSess := s.messageRelayer.RetrieveByScreenName(item.Name)
+			if unblockedSess == nil {
+				continue // unblocked user is offline, nothing to do here
+			}
+			if !sess.Invisible() {
+				// alert unblocked user that current user is online
+				unicastArrival(ctx, sess, unblockedSess, s.messageRelayer)
+			}
+			if !unblockedSess.Invisible() {
+				// alert current user that unblocked user is online
+				unicastArrival(ctx, unblockedSess, sess, s.messageRelayer)
+			}
 		}
 	}
 

+ 985 - 270
handler/feedbag_test.go

@@ -10,32 +10,39 @@ import (
 	"github.com/stretchr/testify/mock"
 )
 
-func TestQueryHandler(t *testing.T) {
+func TestFeedbagService_QueryHandler(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
-		// screenName is the buddy list owner
-		screenName string
-		// feedbagItems is the list of items in user's buddy list
-		feedbagItems []oscar.FeedbagItem
-		// lastModified is the time the buddy list was last changed
-		lastModified time.Time
-		// inputSNAC is the SNAC sent by the sender client
+		// userSession is the session of the user adding to feedbag
+		userSession *state.Session
+		// inputSNAC is the SNAC sent from the client to the server
 		inputSNAC oscar.SNACMessage
-		// expectOutput is the SNAC payload sent from the server to the
-		// recipient client
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// expectOutput is the SNAC sent from the server to client
 		expectOutput oscar.SNACMessage
 	}{
 		{
-			name:         "retrieve empty feedbag",
-			screenName:   "user_screen_name",
-			feedbagItems: []oscar.FeedbagItem{},
-			lastModified: time.UnixMilli(0),
+			name:        "retrieve empty feedbag",
+			userSession: newTestSession("user_screen_name"),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					RequestID: 1234,
 				},
 			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					retrieveParams: retrieveParams{
+						{
+							screenName: "user_screen_name",
+							results:    []oscar.FeedbagItem{},
+						},
+					},
+					lastModifiedParams: lastModifiedParams{},
+				},
+			},
 			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.Feedbag,
@@ -48,22 +55,36 @@ func TestQueryHandler(t *testing.T) {
 			},
 		},
 		{
-			name:       "retrieve feedbag with items",
-			screenName: "user_screen_name",
-			feedbagItems: []oscar.FeedbagItem{
-				{
-					Name: "buddy_1",
-				},
-				{
-					Name: "buddy_2",
-				},
-			},
-			lastModified: time.UnixMilli(1696472198082),
+			name:        "retrieve feedbag with items",
+			userSession: newTestSession("user_screen_name"),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					RequestID: 1234,
 				},
 			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					retrieveParams: retrieveParams{
+						{
+							screenName: "user_screen_name",
+							results: []oscar.FeedbagItem{
+								{
+									Name: "buddy_1",
+								},
+								{
+									Name: "buddy_2",
+								},
+							},
+						},
+					},
+					lastModifiedParams: lastModifiedParams{
+						{
+							screenName: "user_screen_name",
+							result:     time.UnixMilli(1696472198082),
+						},
+					},
+				},
+			},
 			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.Feedbag,
@@ -88,53 +109,45 @@ func TestQueryHandler(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			//
-			// initialize dependencies
-			//
 			feedbagManager := newMockFeedbagManager(t)
-			feedbagManager.EXPECT().
-				Retrieve(tc.screenName).
-				Return(tc.feedbagItems, nil).
-				Maybe()
-			feedbagManager.EXPECT().
-				LastModified(tc.screenName).
-				Return(tc.lastModified, nil).
-				Maybe()
-			//
-			// send input SNAC
-			//
-			senderSession := newTestSession(tc.screenName)
+			for _, params := range tc.mockParams.retrieveParams {
+				feedbagManager.EXPECT().
+					Retrieve(params.screenName).
+					Return(params.results, nil)
+			}
+			for _, params := range tc.mockParams.lastModifiedParams {
+				feedbagManager.EXPECT().
+					LastModified(params.screenName).
+					Return(params.result, nil)
+			}
+
 			svc := FeedbagService{
 				feedbagManager: feedbagManager,
 			}
-			outputSNAC, err := svc.QueryHandler(nil, senderSession, tc.inputSNAC.Frame)
+			outputSNAC, err := svc.QueryHandler(nil, tc.userSession, tc.inputSNAC.Frame)
 			assert.NoError(t, err)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
 	}
 }
 
-func TestQueryIfModifiedHandler(t *testing.T) {
+func TestFeedbagService_QueryIfModifiedHandler(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
-		// screenName is the buddy list owner
-		screenName string
-		// feedbagItems is the list of items in user's buddy list
-		feedbagItems []oscar.FeedbagItem
-		// lastModified is the time the buddy list was last changed
-		lastModified time.Time
-		// inputSNAC is the SNAC sent by the sender client
+		// userSession is the session of the user adding to feedbag
+		userSession *state.Session
+		// inputSNAC is the SNAC sent from the client to the server
 		inputSNAC oscar.SNACMessage
-		// expectOutput is the SNAC payload sent from the server to the
-		// recipient client
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// expectOutput is the SNAC sent from the server to client
 		expectOutput oscar.SNACMessage
 	}{
 		{
-			name:         "retrieve empty feedbag",
-			screenName:   "user_screen_name",
-			feedbagItems: []oscar.FeedbagItem{},
-			lastModified: time.UnixMilli(0),
+			name:        "retrieve empty feedbag",
+			userSession: newTestSession("user_screen_name"),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					RequestID: 1234,
@@ -143,6 +156,16 @@ func TestQueryIfModifiedHandler(t *testing.T) {
 					LastUpdate: uint32(time.UnixMilli(100000).Unix()),
 				},
 			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					retrieveParams: retrieveParams{
+						{
+							screenName: "user_screen_name",
+							results:    []oscar.FeedbagItem{},
+						},
+					},
+				},
+			},
 			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.Feedbag,
@@ -155,17 +178,8 @@ func TestQueryIfModifiedHandler(t *testing.T) {
 			},
 		},
 		{
-			name:       "retrieve feedbag with items",
-			screenName: "user_screen_name",
-			feedbagItems: []oscar.FeedbagItem{
-				{
-					Name: "buddy_1",
-				},
-				{
-					Name: "buddy_2",
-				},
-			},
-			lastModified: time.UnixMilli(200000),
+			name:        "retrieve feedbag with items",
+			userSession: newTestSession("user_screen_name"),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					RequestID: 1234,
@@ -174,6 +188,29 @@ func TestQueryIfModifiedHandler(t *testing.T) {
 					LastUpdate: uint32(time.UnixMilli(100000).Unix()),
 				},
 			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					retrieveParams: retrieveParams{
+						{
+							screenName: "user_screen_name",
+							results: []oscar.FeedbagItem{
+								{
+									Name: "buddy_1",
+								},
+								{
+									Name: "buddy_2",
+								},
+							},
+						},
+					},
+					lastModifiedParams: lastModifiedParams{
+						{
+							screenName: "user_screen_name",
+							result:     time.UnixMilli(200000),
+						},
+					},
+				},
+			},
 			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.Feedbag,
@@ -195,17 +232,8 @@ func TestQueryIfModifiedHandler(t *testing.T) {
 			},
 		},
 		{
-			name:       "retrieve not-modified response",
-			screenName: "user_screen_name",
-			feedbagItems: []oscar.FeedbagItem{
-				{
-					Name: "buddy_1",
-				},
-				{
-					Name: "buddy_2",
-				},
-			},
-			lastModified: time.UnixMilli(100000),
+			name:        "retrieve not-modified response",
+			userSession: newTestSession("user_screen_name"),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					RequestID: 1234,
@@ -214,6 +242,29 @@ func TestQueryIfModifiedHandler(t *testing.T) {
 					LastUpdate: uint32(time.UnixMilli(200000).Unix()),
 				},
 			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					retrieveParams: retrieveParams{
+						{
+							screenName: "user_screen_name",
+							results: []oscar.FeedbagItem{
+								{
+									Name: "buddy_1",
+								},
+								{
+									Name: "buddy_2",
+								},
+							},
+						},
+					},
+					lastModifiedParams: lastModifiedParams{
+						{
+							screenName: "user_screen_name",
+							result:     time.UnixMilli(100000),
+						},
+					},
+				},
+			},
 			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.Feedbag,
@@ -234,22 +285,23 @@ func TestQueryIfModifiedHandler(t *testing.T) {
 			// initialize dependencies
 			//
 			feedbagManager := newMockFeedbagManager(t)
-			feedbagManager.EXPECT().
-				Retrieve(tc.screenName).
-				Return(tc.feedbagItems, nil).
-				Maybe()
-			feedbagManager.EXPECT().
-				LastModified(tc.screenName).
-				Return(tc.lastModified, nil).
-				Maybe()
+			for _, params := range tc.mockParams.retrieveParams {
+				feedbagManager.EXPECT().
+					Retrieve(params.screenName).
+					Return(params.results, nil)
+			}
+			for _, params := range tc.mockParams.lastModifiedParams {
+				feedbagManager.EXPECT().
+					LastModified(params.screenName).
+					Return(params.result, nil)
+			}
 			//
 			// send input SNAC
 			//
-			senderSession := newTestSession(tc.screenName)
 			svc := FeedbagService{
 				feedbagManager: feedbagManager,
 			}
-			outputSNAC, err := svc.QueryIfModifiedHandler(nil, senderSession, tc.inputSNAC.Frame,
+			outputSNAC, err := svc.QueryIfModifiedHandler(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(oscar.SNAC_0x13_0x05_FeedbagQueryIfModified))
 			assert.NoError(t, err)
 			//
@@ -260,31 +312,76 @@ func TestQueryIfModifiedHandler(t *testing.T) {
 	}
 }
 
-func TestInsertItemHandler(t *testing.T) {
+func TestFeedbagService_RightsQueryHandler(t *testing.T) {
+	svc := NewFeedbagService(nil, nil)
+
+	outputSNAC := svc.RightsQueryHandler(nil, oscar.SNACFrame{RequestID: 1234})
+	expectSNAC := oscar.SNACMessage{
+		Frame: oscar.SNACFrame{
+			FoodGroup: oscar.Feedbag,
+			SubGroup:  oscar.FeedbagRightsReply,
+			RequestID: 1234,
+		},
+		Body: oscar.SNAC_0x13_0x03_FeedbagRightsReply{
+			TLVRestBlock: oscar.TLVRestBlock{
+				TLVList: oscar.TLVList{
+					oscar.NewTLV(oscar.FeedbagRightsMaxItemAttrs, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsMaxItemsByClass, []uint16{
+						0x3D,
+						0x3D,
+						0x64,
+						0x64,
+						0x01,
+						0x01,
+						0x32,
+						0x00,
+						0x00,
+						0x03,
+						0x00,
+						0x00,
+						0x00,
+						0x80,
+						0xFF,
+						0x14,
+						0xC8,
+						0x01,
+						0x00,
+						0x01,
+						0x00,
+					}),
+					oscar.NewTLV(oscar.FeedbagRightsMaxClientItems, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsMaxItemNameLen, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsMaxRecentBuddies, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsInteractionBuddies, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsInteractionHalfLife, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsInteractionMaxScore, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsMaxBuddiesPerGroup, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsMaxMegaBots, uint16(200)),
+					oscar.NewTLV(oscar.FeedbagRightsMaxSmartGroups, uint16(100)),
+				},
+			},
+		},
+	}
+
+	assert.Equal(t, expectSNAC, outputSNAC)
+}
+
+func TestFeedbagService_InsertItemHandler(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
-		// userSession is the session of the user managing buddy list
+		// userSession is the session of the user adding to feedbag
 		userSession *state.Session
-		// feedbagItems is the list of items in user's buddy list
-		feedbagItems []oscar.FeedbagItem
-		// inputSNAC is the SNAC sent by the sender client
+		// inputSNAC is the SNAC sent from the client to the server
 		inputSNAC oscar.SNACMessage
-		// screenNameLookups is the list of user's online buddies
-		screenNameLookups map[string]struct {
-			sess *state.Session
-			err  error
-		}
-		// clientResponse is the message returned to the client
-		clientResponse oscar.SNACMessage
-		// buddyMessages are events forwarded to buddy clients
-		buddyMessages []struct {
-			user string
-			msg  oscar.SNACMessage
-		}
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// expectOutput is the SNAC sent from the server to client
+		expectOutput oscar.SNACMessage
 	}{
 		{
-			name:        "user adds 2 online buddies, expect OK response",
+			name:        "user adds online buddies to feedbag, receives buddy arrival notifications",
 			userSession: newTestSession("user_screen_name"),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
@@ -293,82 +390,96 @@ func TestInsertItemHandler(t *testing.T) {
 				Body: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
 					Items: []oscar.FeedbagItem{
 						{
-							ClassID: 2,
+							ClassID: oscar.FeedbagClassIDPermit,
 							Name:    "buddy_1_online",
 						},
 						{
-							ClassID: 2,
+							ClassID: oscar.FeedbagClassIDPermit,
 							Name:    "buddy_2_online",
 						},
 					},
 				},
 			},
-			screenNameLookups: map[string]struct {
-				sess *state.Session
-				err  error
-			}{
-				"user_screen_name": {
-					sess: newTestSession("user_screen_name", sessOptCannedSignonTime),
-				},
-				"buddy_1_online": {
-					sess: newTestSession("buddy_1_online", sessOptCannedSignonTime),
-				},
-				"buddy_2_online": {
-					sess: newTestSession("buddy_2_online", sessOptCannedSignonTime),
-				},
-			},
-			clientResponse: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagStatus,
-					RequestID: 1234,
-				},
-				Body: oscar.SNAC_0x13_0x0E_FeedbagStatus{
-					Results: []uint16{0x0000, 0x0000},
-				},
-			},
-			buddyMessages: []struct {
-				user string
-				msg  oscar.SNACMessage
-			}{
-				{
-					user: "user_screen_name",
-					msg: oscar.SNACMessage{
-						Frame: oscar.SNACFrame{
-							FoodGroup: oscar.Buddy,
-							SubGroup:  oscar.BuddyArrived,
-						},
-						Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
-							TLVUserInfo: oscar.TLVUserInfo{
-								ScreenName: "buddy_1_online",
-								TLVBlock: oscar.TLVBlock{
-									TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					upsertParams: upsertParams{
+						{
+							screenName: "user_screen_name",
+							items: []oscar.FeedbagItem{
+								{
+									ClassID: oscar.FeedbagClassIDPermit,
+									Name:    "buddy_1_online",
+								},
+								{
+									ClassID: oscar.FeedbagClassIDPermit,
+									Name:    "buddy_2_online",
 								},
 							},
 						},
 					},
 				},
-				{
-					user: "user_screen_name",
-					msg: oscar.SNACMessage{
-						Frame: oscar.SNACFrame{
-							FoodGroup: oscar.Buddy,
-							SubGroup:  oscar.BuddyArrived,
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "buddy_1_online",
+							sess:       newTestSession("buddy_1_online", sessOptCannedSignonTime),
+						},
+						{
+							screenName: "buddy_2_online",
+							sess:       newTestSession("buddy_2_online", sessOptCannedSignonTime),
+						},
+					},
+					sendToScreenNameParams: sendToScreenNameParams{
+						{
+							screenName: "user_screen_name",
+							message: oscar.SNACMessage{
+								Frame: oscar.SNACFrame{
+									FoodGroup: oscar.Buddy,
+									SubGroup:  oscar.BuddyArrived,
+								},
+								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: oscar.TLVUserInfo{
+										ScreenName: "buddy_1_online",
+										TLVBlock: oscar.TLVBlock{
+											TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
+										},
+									},
+								},
+							},
 						},
-						Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
-							TLVUserInfo: oscar.TLVUserInfo{
-								ScreenName: "buddy_2_online",
-								TLVBlock: oscar.TLVBlock{
-									TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
+						{
+							screenName: "user_screen_name",
+							message: oscar.SNACMessage{
+								Frame: oscar.SNACFrame{
+									FoodGroup: oscar.Buddy,
+									SubGroup:  oscar.BuddyArrived,
+								},
+								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: oscar.TLVUserInfo{
+										ScreenName: "buddy_2_online",
+										TLVBlock: oscar.TLVBlock{
+											TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
+										},
+									},
 								},
 							},
 						},
 					},
 				},
 			},
+			expectOutput: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					FoodGroup: oscar.Feedbag,
+					SubGroup:  oscar.FeedbagStatus,
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000, 0x0000},
+				},
+			},
 		},
 		{
-			name:        "user adds an offline buddy, expect OK response and 0 buddy arrived events",
+			name:        "user adds offline buddy to feedbag, receives no buddy arrival notification",
 			userSession: newTestSession("user_screen_name"),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
@@ -377,21 +488,36 @@ func TestInsertItemHandler(t *testing.T) {
 				Body: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
 					Items: []oscar.FeedbagItem{
 						{
-							ClassID: 2,
+							ClassID: oscar.FeedbagClassIDPermit,
 							Name:    "buddy_offline",
 						},
 					},
 				},
 			},
-			screenNameLookups: map[string]struct {
-				sess *state.Session
-				err  error
-			}{
-				"buddy_offline": {
-					sess: nil,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					upsertParams: upsertParams{
+						{
+							screenName: "user_screen_name",
+							items: []oscar.FeedbagItem{
+								{
+									ClassID: oscar.FeedbagClassIDPermit,
+									Name:    "buddy_offline",
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "buddy_offline",
+							sess:       nil,
+						},
+					},
 				},
 			},
-			clientResponse: oscar.SNACMessage{
+			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.Feedbag,
 					SubGroup:  oscar.FeedbagStatus,
@@ -403,7 +529,7 @@ func TestInsertItemHandler(t *testing.T) {
 			},
 		},
 		{
-			name:        "users adds an invisible buddy, expect OK response and 0 buddy arrived events",
+			name:        "user adds invisible buddy to feedbag, receives no buddy arrival notification",
 			userSession: newTestSession("user_screen_name"),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
@@ -412,21 +538,36 @@ func TestInsertItemHandler(t *testing.T) {
 				Body: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
 					Items: []oscar.FeedbagItem{
 						{
-							ClassID: 2,
+							ClassID: oscar.FeedbagClassIDPermit,
 							Name:    "invisible_buddy_online",
 						},
 					},
 				},
 			},
-			screenNameLookups: map[string]struct {
-				sess *state.Session
-				err  error
-			}{
-				"invisible_buddy_online": {
-					sess: newTestSession("invisible_buddy_online", sessOptInvisible),
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					upsertParams: upsertParams{
+						{
+							screenName: "user_screen_name",
+							items: []oscar.FeedbagItem{
+								{
+									ClassID: oscar.FeedbagClassIDPermit,
+									Name:    "invisible_buddy_online",
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "invisible_buddy_online",
+							sess:       newTestSession("invisible_buddy_online", sessOptInvisible),
+						},
+					},
 				},
 			},
-			clientResponse: oscar.SNACMessage{
+			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.Feedbag,
 					SubGroup:  oscar.FeedbagStatus,
@@ -438,8 +579,7 @@ func TestInsertItemHandler(t *testing.T) {
 			},
 		},
 		{
-			name: "user blocks buddy currently online, expect OK response, buddy departed event client, 1 buddy " +
-				"departed event sent to buddy",
+			name:        "user blocks online buddy, buddy receives buddy departure notification",
 			userSession: newTestSession("user_screen_name"),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
@@ -448,59 +588,53 @@ func TestInsertItemHandler(t *testing.T) {
 				Body: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
 					Items: []oscar.FeedbagItem{
 						{
-							ClassID: 3,
+							ClassID: oscar.FeedbagClassIDDeny,
 							Name:    "buddy_1",
 						},
 					},
 				},
 			},
-			screenNameLookups: map[string]struct {
-				sess *state.Session
-				err  error
-			}{
-				"user_screen_name": {
-					sess: newTestSession("user_screen_name"),
-				},
-				"buddy_1": {
-					sess: newTestSession("buddy_1"),
-				},
-			},
-			buddyMessages: []struct {
-				user string
-				msg  oscar.SNACMessage
-			}{
-				{
-					user: "buddy_1",
-					msg: oscar.SNACMessage{
-						Frame: oscar.SNACFrame{
-							FoodGroup: oscar.Buddy,
-							SubGroup:  oscar.BuddyDeparted,
-						},
-						Body: oscar.SNAC_0x03_0x0C_BuddyDeparted{
-							TLVUserInfo: oscar.TLVUserInfo{
-								ScreenName:   "user_screen_name",
-								WarningLevel: 0,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					upsertParams: upsertParams{
+						{
+							screenName: "user_screen_name",
+							items: []oscar.FeedbagItem{
+								{
+									ClassID: oscar.FeedbagClassIDDeny,
+									Name:    "buddy_1",
+								},
 							},
 						},
 					},
 				},
-				{
-					user: "user_screen_name",
-					msg: oscar.SNACMessage{
-						Frame: oscar.SNACFrame{
-							FoodGroup: oscar.Buddy,
-							SubGroup:  oscar.BuddyDeparted,
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "buddy_1",
+							sess:       newTestSession("buddy_1"),
 						},
-						Body: oscar.SNAC_0x03_0x0C_BuddyDeparted{
-							TLVUserInfo: oscar.TLVUserInfo{
-								ScreenName:   "buddy_1",
-								WarningLevel: 0,
+					},
+					sendToScreenNameParams: sendToScreenNameParams{
+						{
+							screenName: "buddy_1",
+							message: oscar.SNACMessage{
+								Frame: oscar.SNACFrame{
+									FoodGroup: oscar.Buddy,
+									SubGroup:  oscar.BuddyDeparted,
+								},
+								Body: oscar.SNAC_0x03_0x0C_BuddyDeparted{
+									TLVUserInfo: oscar.TLVUserInfo{
+										ScreenName:   "user_screen_name",
+										WarningLevel: 0,
+									},
+								},
 							},
 						},
 					},
 				},
 			},
-			clientResponse: oscar.SNACMessage{
+			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.Feedbag,
 					SubGroup:  oscar.FeedbagStatus,
@@ -512,7 +646,7 @@ func TestInsertItemHandler(t *testing.T) {
 			},
 		},
 		{
-			name:        "user blocks buddy currently offline, expect OK response and a superfluous buddy departed events",
+			name:        "user blocks offline buddy, no buddy departure notification sent",
 			userSession: newTestSession("user_screen_name"),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
@@ -521,24 +655,36 @@ func TestInsertItemHandler(t *testing.T) {
 				Body: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
 					Items: []oscar.FeedbagItem{
 						{
-							ClassID: 3,
+							ClassID: oscar.FeedbagClassIDDeny,
 							Name:    "buddy_1",
 						},
 					},
 				},
 			},
-			screenNameLookups: map[string]struct {
-				sess *state.Session
-				err  error
-			}{
-				"user_screen_name": {
-					sess: newTestSession("user_screen_name"),
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					upsertParams: upsertParams{
+						{
+							screenName: "user_screen_name",
+							items: []oscar.FeedbagItem{
+								{
+									ClassID: oscar.FeedbagClassIDDeny,
+									Name:    "buddy_1",
+								},
+							},
+						},
+					},
 				},
-				"buddy_1": {
-					sess: nil,
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "buddy_1",
+							sess:       nil,
+						},
+					},
 				},
 			},
-			clientResponse: oscar.SNACMessage{
+			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.Feedbag,
 					SubGroup:  oscar.FeedbagStatus,
@@ -548,29 +694,51 @@ func TestInsertItemHandler(t *testing.T) {
 					Results: []uint16{0x0000},
 				},
 			},
-			buddyMessages: []struct {
-				user string
-				msg  oscar.SNACMessage
-			}{
-				{
-					user: "buddy_1",
-					msg: oscar.SNACMessage{
-						Frame: oscar.SNACFrame{
-							FoodGroup: oscar.Buddy,
-							SubGroup:  oscar.BuddyDeparted,
-						},
-						Body: oscar.SNAC_0x03_0x0C_BuddyDeparted{
-							TLVUserInfo: oscar.TLVUserInfo{
-								ScreenName:   "user_screen_name",
-								WarningLevel: 0,
+		},
+		{
+			name:        "invisible user blocks online buddy, no buddy departure notification sent",
+			userSession: newTestSession("user_screen_name", sessOptInvisible),
+			inputSNAC: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
+					Items: []oscar.FeedbagItem{
+						{
+							ClassID: oscar.FeedbagClassIDDeny,
+							Name:    "buddy_1",
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					upsertParams: upsertParams{
+						{
+							screenName: "user_screen_name",
+							items: []oscar.FeedbagItem{
+								{
+									ClassID: oscar.FeedbagClassIDDeny,
+									Name:    "buddy_1",
+								},
 							},
 						},
 					},
 				},
 			},
+			expectOutput: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					FoodGroup: oscar.Feedbag,
+					SubGroup:  oscar.FeedbagStatus,
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000},
+				},
+			},
 		},
 		{
-			name:        "user tries to block themselves, expect feedback error",
+			name:        "user blocks themselves, receives error",
 			userSession: newTestSession("user_screen_name"),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
@@ -579,13 +747,13 @@ func TestInsertItemHandler(t *testing.T) {
 				Body: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
 					Items: []oscar.FeedbagItem{
 						{
-							ClassID: 3,
+							ClassID: oscar.FeedbagClassIDDeny,
 							Name:    "user_screen_name",
 						},
 					},
 				},
 			},
-			clientResponse: oscar.SNACMessage{
+			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.Feedbag,
 					SubGroup:  oscar.FeedbagErr,
@@ -600,33 +768,23 @@ func TestInsertItemHandler(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			//
-			// initialize dependencies
-			//
 			feedbagManager := newMockFeedbagManager(t)
-			feedbagManager.EXPECT().
-				Upsert(tc.userSession.ScreenName(), tc.inputSNAC.Body.(oscar.SNAC_0x13_0x08_FeedbagInsertItem).Items).
-				Return(nil).
-				Maybe()
-			feedbagManager.EXPECT().
-				Buddies(tc.userSession.ScreenName()).
-				Return([]string{}, nil).
-				Maybe()
+			for _, params := range tc.mockParams.feedbagManagerParams.upsertParams {
+				feedbagManager.EXPECT().
+					Upsert(params.screenName, params.items).
+					Return(nil)
+			}
 			messageRelayer := newMockMessageRelayer(t)
-			for screenName, val := range tc.screenNameLookups {
+			for _, params := range tc.mockParams.messageRelayerParams.retrieveByScreenNameParams {
 				messageRelayer.EXPECT().
-					RetrieveByScreenName(screenName).
-					Return(val.sess).
-					Maybe()
+					RetrieveByScreenName(params.screenName).
+					Return(params.sess)
 			}
-			for _, n := range tc.buddyMessages {
+			for _, params := range tc.mockParams.messageRelayerParams.sendToScreenNameParams {
 				messageRelayer.EXPECT().
-					SendToScreenName(mock.Anything, n.user, n.msg).
-					Maybe()
+					SendToScreenName(mock.Anything, params.screenName, params.message)
 			}
-			//
-			// send input SNAC
-			//
+
 			svc := FeedbagService{
 				feedbagManager: feedbagManager,
 				messageRelayer: messageRelayer,
@@ -634,10 +792,567 @@ func TestInsertItemHandler(t *testing.T) {
 			output, err := svc.InsertItemHandler(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(oscar.SNAC_0x13_0x08_FeedbagInsertItem))
 			assert.NoError(t, err)
-			//
-			// verify response
-			//
-			assert.Equal(t, output, tc.clientResponse)
+			assert.Equal(t, output, tc.expectOutput)
+		})
+	}
+}
+
+func TestFeedbagService_UpdateItemHandler(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// userSession is the session of the user adding to feedbag
+		userSession *state.Session
+		// inputSNAC is the SNAC sent from the client to the server
+		inputSNAC oscar.SNACMessage
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// expectOutput is the SNAC sent from the server to client
+		expectOutput oscar.SNACMessage
+	}{
+		{
+			name:        "user updates online buddies in feedbag, receives buddy arrival notifications",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x09_FeedbagUpdateItem{
+					Items: []oscar.FeedbagItem{
+						{
+							ClassID: oscar.FeedbagClassIDPermit,
+							Name:    "buddy_1_online",
+						},
+						{
+							ClassID: oscar.FeedbagClassIDPermit,
+							Name:    "buddy_2_online",
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					upsertParams: upsertParams{
+						{
+							screenName: "user_screen_name",
+							items: []oscar.FeedbagItem{
+								{
+									ClassID: oscar.FeedbagClassIDPermit,
+									Name:    "buddy_1_online",
+								},
+								{
+									ClassID: oscar.FeedbagClassIDPermit,
+									Name:    "buddy_2_online",
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "buddy_1_online",
+							sess:       newTestSession("buddy_1_online", sessOptCannedSignonTime),
+						},
+						{
+							screenName: "buddy_2_online",
+							sess:       newTestSession("buddy_2_online", sessOptCannedSignonTime),
+						},
+					},
+					sendToScreenNameParams: sendToScreenNameParams{
+						{
+							screenName: "user_screen_name",
+							message: oscar.SNACMessage{
+								Frame: oscar.SNACFrame{
+									FoodGroup: oscar.Buddy,
+									SubGroup:  oscar.BuddyArrived,
+								},
+								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: oscar.TLVUserInfo{
+										ScreenName: "buddy_1_online",
+										TLVBlock: oscar.TLVBlock{
+											TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: "user_screen_name",
+							message: oscar.SNACMessage{
+								Frame: oscar.SNACFrame{
+									FoodGroup: oscar.Buddy,
+									SubGroup:  oscar.BuddyArrived,
+								},
+								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: oscar.TLVUserInfo{
+										ScreenName: "buddy_2_online",
+										TLVBlock: oscar.TLVBlock{
+											TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			expectOutput: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					FoodGroup: oscar.Feedbag,
+					SubGroup:  oscar.FeedbagStatus,
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000, 0x0000},
+				},
+			},
+		},
+		{
+			name:        "user updates offline buddy in feedbag, receives no buddy arrival notification",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x09_FeedbagUpdateItem{
+					Items: []oscar.FeedbagItem{
+						{
+							ClassID: oscar.FeedbagClassIDPermit,
+							Name:    "buddy_offline",
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					upsertParams: upsertParams{
+						{
+							screenName: "user_screen_name",
+							items: []oscar.FeedbagItem{
+								{
+									ClassID: oscar.FeedbagClassIDPermit,
+									Name:    "buddy_offline",
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "buddy_offline",
+							sess:       nil,
+						},
+					},
+				},
+			},
+			expectOutput: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					FoodGroup: oscar.Feedbag,
+					SubGroup:  oscar.FeedbagStatus,
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000},
+				},
+			},
+		},
+		{
+			name:        "user updates an invisible buddy in feedbag, receives no buddy arrival notification",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x09_FeedbagUpdateItem{
+					Items: []oscar.FeedbagItem{
+						{
+							ClassID: oscar.FeedbagClassIDPermit,
+							Name:    "invisible_buddy_online",
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					upsertParams: upsertParams{
+						{
+							screenName: "user_screen_name",
+							items: []oscar.FeedbagItem{
+								{
+									ClassID: oscar.FeedbagClassIDPermit,
+									Name:    "invisible_buddy_online",
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "invisible_buddy_online",
+							sess:       newTestSession("invisible_buddy_online", sessOptInvisible),
+						},
+					},
+				},
+			},
+			expectOutput: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					FoodGroup: oscar.Feedbag,
+					SubGroup:  oscar.FeedbagStatus,
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagManagerParams.upsertParams {
+				feedbagManager.EXPECT().
+					Upsert(params.screenName, params.items).
+					Return(nil)
+			}
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tc.mockParams.messageRelayerParams.retrieveByScreenNameParams {
+				messageRelayer.EXPECT().
+					RetrieveByScreenName(params.screenName).
+					Return(params.sess)
+			}
+			for _, params := range tc.mockParams.messageRelayerParams.sendToScreenNameParams {
+				messageRelayer.EXPECT().
+					SendToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			svc := FeedbagService{
+				feedbagManager: feedbagManager,
+				messageRelayer: messageRelayer,
+			}
+			output, err := svc.UpdateItemHandler(nil, tc.userSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(oscar.SNAC_0x13_0x09_FeedbagUpdateItem))
+			assert.NoError(t, err)
+			assert.Equal(t, output, tc.expectOutput)
+		})
+	}
+}
+
+func TestFeedbagService_DeleteItemHandler(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// userSession is the session of the user adding to feedbag
+		userSession *state.Session
+		// inputSNAC is the SNAC sent from the client to the server
+		inputSNAC oscar.SNACMessage
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// expectOutput is the SNAC sent from the server to client
+		expectOutput oscar.SNACMessage
+	}{
+		{
+			name:        "user deletes buddy",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x0A_FeedbagDeleteItem{
+					Items: []oscar.FeedbagItem{
+						{
+							ClassID: oscar.FeedbagClassIdBuddy,
+							Name:    "buddy_1_online",
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					deleteParams: deleteParams{
+						{
+							screenName: "user_screen_name",
+							items: []oscar.FeedbagItem{
+								{
+									ClassID: oscar.FeedbagClassIdBuddy,
+									Name:    "buddy_1_online",
+								},
+							},
+						},
+					},
+				},
+			},
+			expectOutput: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					FoodGroup: oscar.Feedbag,
+					SubGroup:  oscar.FeedbagStatus,
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000},
+				},
+			},
+		},
+		{
+			name:        "user unblocks buddies, user and buddies receive buddy arrival notifications",
+			userSession: newTestSession("user_screen_name", sessOptCannedSignonTime),
+			inputSNAC: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x0A_FeedbagDeleteItem{
+					Items: []oscar.FeedbagItem{
+						{
+							ClassID: oscar.FeedbagClassIDDeny,
+							Name:    "buddy_1_online",
+						},
+						{
+							ClassID: oscar.FeedbagClassIDDeny,
+							Name:    "buddy_2_online",
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					deleteParams: deleteParams{
+						{
+							screenName: "user_screen_name",
+							items: []oscar.FeedbagItem{
+								{
+									ClassID: oscar.FeedbagClassIDDeny,
+									Name:    "buddy_1_online",
+								},
+								{
+									ClassID: oscar.FeedbagClassIDDeny,
+									Name:    "buddy_2_online",
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "buddy_1_online",
+							sess:       newTestSession("buddy_1_online", sessOptCannedSignonTime),
+						},
+						{
+							screenName: "buddy_2_online",
+							sess:       newTestSession("buddy_2_online", sessOptCannedSignonTime),
+						},
+					},
+					sendToScreenNameParams: sendToScreenNameParams{
+						{
+							screenName: "user_screen_name",
+							message: oscar.SNACMessage{
+								Frame: oscar.SNACFrame{
+									FoodGroup: oscar.Buddy,
+									SubGroup:  oscar.BuddyArrived,
+								},
+								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: newTestSession("buddy_1_online", sessOptCannedSignonTime).TLVUserInfo(),
+								},
+							},
+						},
+						{
+							screenName: "buddy_1_online",
+							message: oscar.SNACMessage{
+								Frame: oscar.SNACFrame{
+									FoodGroup: oscar.Buddy,
+									SubGroup:  oscar.BuddyArrived,
+								},
+								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: newTestSession("user_screen_name", sessOptCannedSignonTime).TLVUserInfo(),
+								},
+							},
+						},
+						{
+							screenName: "user_screen_name",
+							message: oscar.SNACMessage{
+								Frame: oscar.SNACFrame{
+									FoodGroup: oscar.Buddy,
+									SubGroup:  oscar.BuddyArrived,
+								},
+								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: newTestSession("buddy_2_online", sessOptCannedSignonTime).TLVUserInfo(),
+								},
+							},
+						},
+						{
+							screenName: "buddy_2_online",
+							message: oscar.SNACMessage{
+								Frame: oscar.SNACFrame{
+									FoodGroup: oscar.Buddy,
+									SubGroup:  oscar.BuddyArrived,
+								},
+								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: newTestSession("user_screen_name", sessOptCannedSignonTime).TLVUserInfo(),
+								},
+							},
+						},
+					},
+				},
+			},
+			expectOutput: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					FoodGroup: oscar.Feedbag,
+					SubGroup:  oscar.FeedbagStatus,
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000, 0x0000},
+				},
+			},
+		},
+		{
+			name:        "user unblocks offline buddy, receives no buddy arrival notifications",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x0A_FeedbagDeleteItem{
+					Items: []oscar.FeedbagItem{
+						{
+							ClassID: oscar.FeedbagClassIDDeny,
+							Name:    "buddy_offline",
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					deleteParams: deleteParams{
+						{
+							screenName: "user_screen_name",
+							items: []oscar.FeedbagItem{
+								{
+									ClassID: oscar.FeedbagClassIDDeny,
+									Name:    "buddy_offline",
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "buddy_offline",
+							sess:       nil,
+						},
+					},
+				},
+			},
+			expectOutput: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					FoodGroup: oscar.Feedbag,
+					SubGroup:  oscar.FeedbagStatus,
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000},
+				},
+			},
+		},
+		{
+			name:        "user unblocks invisible buddy, user receives no buddy arrival notification, buddy receives buddy arrival notifications",
+			userSession: newTestSession("user_screen_name", sessOptCannedSignonTime),
+			inputSNAC: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x0A_FeedbagDeleteItem{
+					Items: []oscar.FeedbagItem{
+						{
+							ClassID: oscar.FeedbagClassIDDeny,
+							Name:    "invisible_buddy_online",
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					deleteParams: deleteParams{
+						{
+							screenName: "user_screen_name",
+							items: []oscar.FeedbagItem{
+								{
+									ClassID: oscar.FeedbagClassIDDeny,
+									Name:    "invisible_buddy_online",
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "invisible_buddy_online",
+							sess:       newTestSession("invisible_buddy_online", sessOptInvisible),
+						},
+					},
+					sendToScreenNameParams: sendToScreenNameParams{
+						{
+							screenName: "invisible_buddy_online",
+							message: oscar.SNACMessage{
+								Frame: oscar.SNACFrame{
+									FoodGroup: oscar.Buddy,
+									SubGroup:  oscar.BuddyArrived,
+								},
+								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: newTestSession("user_screen_name", sessOptCannedSignonTime).TLVUserInfo(),
+								},
+							},
+						},
+					},
+				},
+			},
+			expectOutput: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					FoodGroup: oscar.Feedbag,
+					SubGroup:  oscar.FeedbagStatus,
+					RequestID: 1234,
+				},
+				Body: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagManagerParams.deleteParams {
+				feedbagManager.EXPECT().
+					Delete(params.screenName, params.items).
+					Return(nil)
+			}
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tc.mockParams.messageRelayerParams.retrieveByScreenNameParams {
+				messageRelayer.EXPECT().
+					RetrieveByScreenName(params.screenName).
+					Return(params.sess)
+			}
+			for _, params := range tc.mockParams.messageRelayerParams.sendToScreenNameParams {
+				messageRelayer.EXPECT().
+					SendToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			svc := FeedbagService{
+				feedbagManager: feedbagManager,
+				messageRelayer: messageRelayer,
+			}
+			output, err := svc.DeleteItemHandler(nil, tc.userSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(oscar.SNAC_0x13_0x0A_FeedbagDeleteItem))
+			assert.NoError(t, err)
+			assert.Equal(t, output, tc.expectOutput)
 		})
 	}
 }

+ 6 - 2
handler/oservice.go

@@ -416,8 +416,12 @@ func (s OServiceServiceForBOS) ClientOnlineHandler(ctx context.Context, _ oscar.
 	if err != nil {
 		return err
 	}
-	for _, buddy := range buddies {
-		unicastArrival(ctx, buddy, sess.ScreenName(), s.messageRelayer)
+	for _, screenName := range buddies {
+		buddy := s.messageRelayer.RetrieveByScreenName(screenName)
+		if buddy == nil || buddy.Invisible() {
+			continue
+		}
+		unicastArrival(ctx, buddy, sess, s.messageRelayer)
 	}
 	return nil
 }

+ 5 - 14
handler/oservice_test.go

@@ -672,10 +672,6 @@ func TestOServiceServiceForBOS_ClientOnlineHandler(t *testing.T) {
 		screenName string
 		buddies    []string
 	}
-	type sendToScreenNameParams []struct {
-		screenName string
-		message    oscar.SNACMessage
-	}
 
 	tests := []struct {
 		// name is the name of the test
@@ -779,30 +775,25 @@ func TestOServiceServiceForBOS_ClientOnlineHandler(t *testing.T) {
 			for _, params := range tt.interestedUsersParams {
 				feedbagManager.EXPECT().
 					InterestedUsers(params.screenName).
-					Return(params.users, nil).
-					Maybe()
+					Return(params.users, nil)
 			}
 			for _, params := range tt.broadcastToScreenNamesParams {
 				messageRelayer.EXPECT().
-					BroadcastToScreenNames(mock.Anything, params.screenNames, params.message).
-					Maybe()
+					BroadcastToScreenNames(mock.Anything, params.screenNames, params.message)
 			}
 			for _, params := range tt.buddyLookupParams {
 				feedbagManager.EXPECT().
 					Buddies(params.screenName).
-					Return(params.buddies, nil).
-					Maybe()
+					Return(params.buddies, nil)
 			}
 			for _, params := range tt.retrieveByScreenNameParams {
 				messageRelayer.EXPECT().
 					RetrieveByScreenName(params.screenName).
-					Return(params.sess).
-					Maybe()
+					Return(params.sess)
 			}
 			for _, params := range tt.sendToScreenNameParams {
 				messageRelayer.EXPECT().
-					SendToScreenName(mock.Anything, params.screenName, params.message).
-					Maybe()
+					SendToScreenName(mock.Anything, params.screenName, params.message)
 			}
 
 			svc := NewOServiceServiceForBOS(OServiceService{

+ 48 - 0
handler/test_helpers.go

@@ -20,6 +20,11 @@ type mockParams struct {
 type feedbagManagerParams struct {
 	blockedParams
 	interestedUsersParams
+	upsertParams
+	buddiesParams
+	retrieveParams
+	lastModifiedParams
+	deleteParams
 }
 
 // blockedParams is the list of parameters passed at the mock
@@ -38,11 +43,47 @@ type interestedUsersParams []struct {
 	users      []string
 }
 
+// upsertParams is the list of parameters passed at the mock
+// FeedbagManager.Upsert call site
+type upsertParams []struct {
+	screenName string
+	items      []oscar.FeedbagItem
+}
+
+// buddiesParams is the list of parameters passed at the mock
+// FeedbagManager.Buddies call site
+type buddiesParams []struct {
+	screenName string
+	results    []string
+}
+
+// retrieveParams is the list of parameters passed at the mock
+// FeedbagManager.Retrieve call site
+type retrieveParams []struct {
+	screenName string
+	results    []oscar.FeedbagItem
+}
+
+// lastModifiedParams is the list of parameters passed at the mock
+// FeedbagManager.LastModified call site
+type lastModifiedParams []struct {
+	screenName string
+	result     time.Time
+}
+
+// deleteParams is the list of parameters passed at the mock
+// FeedbagManager.Delete call site
+type deleteParams []struct {
+	screenName string
+	items      []oscar.FeedbagItem
+}
+
 // messageRelayerParams is a helper struct that contains mock parameters for
 // MessageRelayer methods
 type messageRelayerParams struct {
 	retrieveByScreenNameParams
 	broadcastToScreenNamesParams
+	sendToScreenNameParams
 }
 
 // retrieveByScreenNameParams is the list of parameters passed at the mock
@@ -59,6 +100,13 @@ type broadcastToScreenNamesParams []struct {
 	message     oscar.SNACMessage
 }
 
+// sendToScreenNameParams is the list of parameters passed at the mock
+// MessageRelayer.SendToScreenName call site
+type sendToScreenNameParams []struct {
+	screenName string
+	message    oscar.SNACMessage
+}
+
 // profileManagerParams is a helper struct that contains mock parameters for
 // ProfileManager methods
 type profileManagerParams struct {

+ 14 - 0
oscar/snacs.go

@@ -698,6 +698,20 @@ const (
 	FeedbagAttributesFirstCreationTimeXc     uint16 = 0x0167
 	FeedbagAttributesPdModeXc                uint16 = 0x016E
 
+	FeedbagRightsMaxClassAttrs       uint16 = 0x02
+	FeedbagRightsMaxItemAttrs        uint16 = 0x03
+	FeedbagRightsMaxItemsByClass     uint16 = 0x04
+	FeedbagRightsMaxClientItems      uint16 = 0x05
+	FeedbagRightsMaxItemNameLen      uint16 = 0x06
+	FeedbagRightsMaxRecentBuddies    uint16 = 0x07
+	FeedbagRightsInteractionBuddies  uint16 = 0x08
+	FeedbagRightsInteractionHalfLife uint16 = 0x09
+	FeedbagRightsInteractionMaxScore uint16 = 0x0A
+	FeedbagRightsMaxUnknown0b        uint16 = 0x0B
+	FeedbagRightsMaxBuddiesPerGroup  uint16 = 0x0C
+	FeedbagRightsMaxMegaBots         uint16 = 0x0D
+	FeedbagRightsMaxSmartGroups      uint16 = 0x0E
+
 	FeedbagErr                      uint16 = 0x0001
 	FeedbagRightsQuery              uint16 = 0x0002
 	FeedbagRightsReply              uint16 = 0x0003