Explorar o código

issue #64 - feedbag endpoint for adding/removing buddies

wip

complete PUT /feedbag/{screen_name}/buddy/{group_id}

complete GET /feedbag/{screen_name}/buddy

complete DELETE /feedbag/{screen_name}/buddy/{group_id}

change up routes
Mike hai 5 meses
pai
achega
0b18d2dd9e

+ 3 - 0
.mockery.yaml

@@ -77,6 +77,9 @@ packages:
       BARTAssetManager:
         config:
           filename: "mock_bart_asset_manager_test.go"
+      BuddyBroadcaster:
+        config:
+          filename: "mock_buddy_broadcaster_test.go"
       ChatRoomCreator:
         config:
           filename: "mock_chat_room_creator_test.go"

+ 165 - 0
api.yml

@@ -1200,6 +1200,171 @@ paths:
               schema:
                 $ref: '#/components/schemas/MessageResponse'
 
+  /feedbag/{screen_name}/group:
+    get:
+      summary: Get buddy list for a user
+      description: Retrieve all buddies for a user, grouped by group ID.
+      parameters:
+        - name: screen_name
+          in: path
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+          schema:
+            type: string
+      responses:
+        '200':
+          description: Successful response containing buddy list grouped by group ID.
+          content:
+            application/json:
+              schema:
+                type: array
+                description: List of buddy groups.
+                items:
+                  type: object
+                  properties:
+                    group_id:
+                      type: integer
+                      description: The group ID.
+                    group_name:
+                      type: string
+                      description: The name of the group.
+                    buddies:
+                      type: array
+                      description: List of buddies in this group.
+                      items:
+                        type: object
+                        properties:
+                          name:
+                            type: string
+                            description: The buddy's screen name.
+                          item_id:
+                            type: integer
+                            description: The feedbag item ID for this buddy.
+                  required:
+                    - group_id
+                    - group_name
+                    - buddies
+        '404':
+          description: User or feedbag not found.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+
+  /feedbag/{screen_name}/group/{group_id}/buddy/{buddy_screen_name}:
+    put:
+      summary: Add a buddy to a group
+      description: Add a buddy to a specific group in the user's feedbag. This operation is idempotent - if the buddy already exists in the group, the request will succeed without error.
+      parameters:
+        - name: screen_name
+          in: path
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+          schema:
+            type: string
+        - name: group_id
+          in: path
+          description: The group ID to add the buddy to.
+          required: true
+          schema:
+            type: integer
+        - name: buddy_screen_name
+          in: path
+          description: The screen name of the buddy to add.
+          required: true
+          schema:
+            type: string
+          example: "buddy123"
+      responses:
+        '200':
+          description: Buddy added or already exists in the group. Operation is idempotent.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  name:
+                    type: string
+                    description: The buddy's screen name.
+                  group_id:
+                    type: integer
+                    description: The group ID the buddy was added to.
+                  item_id:
+                    type: integer
+                    description: The feedbag item ID for this buddy.
+                required:
+                  - name
+                  - group_id
+                  - item_id
+        '400':
+          description: Bad request. Invalid input data.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '404':
+          description: User or group not found.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+    delete:
+      summary: Delete a buddy from a group
+      description: Remove a buddy from a specific group in the user's feedbag.
+      parameters:
+        - name: screen_name
+          in: path
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+          schema:
+            type: string
+        - name: group_id
+          in: path
+          description: The group ID to remove the buddy from.
+          required: true
+          schema:
+            type: integer
+        - name: buddy_screen_name
+          in: path
+          description: The screen name of the buddy to remove.
+          required: true
+          schema:
+            type: string
+          example: "buddy123"
+      responses:
+        '204':
+          description: Buddy deleted successfully.
+        '400':
+          description: Bad request. Invalid input data.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '404':
+          description: User, group, or buddy not found.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+
 
 components:
   schemas:

+ 9 - 0
cmd/server/factory.go

@@ -350,11 +350,19 @@ func MgmtAPI(deps Container) *http.Server {
 		Date:    date,
 	}
 	logger := deps.logger.With("svc", "API")
+	buddyService := foodgroup.NewBuddyService(
+		deps.inMemorySessionManager,
+		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
+		deps.inMemorySessionManager,
+		deps.sqLiteUserStore,
+	)
 	return http.NewManagementAPI(
 		bld,
 		deps.cfg.APIListener,
 		deps.sqLiteUserStore,        // userManager
 		deps.inMemorySessionManager, // sessionRetriever
+		buddyService,
 		deps.sqLiteUserStore,        // chatRoomRetriever
 		deps.sqLiteUserStore,        // chatRoomCreator
 		deps.sqLiteUserStore,        // chatRoomDeleter
@@ -363,6 +371,7 @@ func MgmtAPI(deps Container) *http.Server {
 		deps.inMemorySessionManager, // messageRelayer
 		deps.sqLiteUserStore,        // bartAssetManager
 		deps.sqLiteUserStore,        // feedbagRetriever
+		deps.sqLiteUserStore,        // feedbagManager
 		deps.sqLiteUserStore,        // accountManager
 		deps.sqLiteUserStore,        // profileRetriever
 		deps.sqLiteUserStore,        // webAPIKeyManager

+ 4 - 0
foodgroup/buddy.go

@@ -132,6 +132,10 @@ func (s BuddyService) BroadcastBuddyDeparted(ctx context.Context, instance *stat
 	return s.buddyBroadcaster.BroadcastBuddyDeparted(ctx, instance)
 }
 
+func (s BuddyService) BroadcastVisibility(ctx context.Context, you *state.SessionInstance, filter []state.IdentScreenName, doSendDepartures bool) error {
+	return s.buddyBroadcaster.BroadcastVisibility(ctx, you, filter, doSendDepartures)
+}
+
 func newBuddyNotifier(
 	bartItemManager BARTItemManager,
 	relationshipFetcher RelationshipFetcher,

+ 63 - 0
server/http/helpers_test.go

@@ -13,11 +13,14 @@ import (
 type mockParams struct {
 	accountManagerParams
 	bartAssetManagerParams
+	buddyBroadcasterParams
 	chatRoomDeleterParams
 	chatRoomRetrieverParams
 	chatSessionRetrieverParams
 	directoryManagerParams
 	feedBagRetrieverParams
+	feedbagManagerParams
+	messageRelayerParams
 	profileRetrieverParams
 	sessionRetrieverParams
 	userManagerParams
@@ -114,6 +117,21 @@ type deleteBARTItemParams []struct {
 	err  error
 }
 
+// buddyBroadcasterParams is a helper struct that contains mock parameters for
+// BuddyBroadcaster methods
+type buddyBroadcasterParams struct {
+	broadcastVisibilityParams
+}
+
+// broadcastVisibilityParams is the list of parameters passed at the mock
+// BuddyBroadcaster.BroadcastVisibility call site
+type broadcastVisibilityParams []struct {
+	you            *state.SessionInstance
+	filter         []state.IdentScreenName
+	sendDepartures bool
+	err            error
+}
+
 // chatRoomRetrieverParams is a helper struct that contains mock parameters for
 // ChatRoomRetriever methods
 type chatRoomRetrieverParams struct {
@@ -224,6 +242,51 @@ type buddyIconMetadataParams []struct {
 	err        error
 }
 
+// feedbagManagerParams is a helper struct that contains mock parameters for
+// FeedbagManager methods
+type feedbagManagerParams struct {
+	feedbagParams
+	feedbagUpsertParams
+	feedbagDeleteParams
+}
+
+// feedbagParams is the list of parameters passed at the mock
+// FeedbagManager.Feedbag call site
+type feedbagParams []struct {
+	screenName state.IdentScreenName
+	result     []wire.FeedbagItem
+	err        error
+}
+
+// feedbagUpsertParams is the list of parameters passed at the mock
+// FeedbagManager.FeedbagUpsert call site
+type feedbagUpsertParams []struct {
+	screenName state.IdentScreenName
+	items      []wire.FeedbagItem
+	err        error
+}
+
+// feedbagDeleteParams is the list of parameters passed at the mock
+// FeedbagManager.FeedbagDelete call site
+type feedbagDeleteParams []struct {
+	screenName state.IdentScreenName
+	items      []wire.FeedbagItem
+	err        error
+}
+
+// messageRelayerParams is a helper struct that contains mock parameters for
+// MessageRelayer methods
+type messageRelayerParams struct {
+	relayToScreenNameParams
+}
+
+// relayToScreenNameParams is the list of parameters passed at the mock
+// MessageRelayer.RelayToScreenName call site
+type relayToScreenNameParams []struct {
+	screenName state.IdentScreenName
+	msg        wire.SNACMessage
+}
+
 // profileRetrieverParams is a helper struct that contains mock parameters for
 // ProfileRetriever methods
 type profileRetrieverParams struct {

+ 336 - 1
server/http/mgmt_api.go

@@ -10,6 +10,8 @@ import (
 	"fmt"
 	"io"
 	"log/slog"
+	"math"
+	"math/rand"
 	"net/http"
 	"strconv"
 	"strings"
@@ -22,7 +24,7 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-func NewManagementAPI(bld config.Build, listener string, userManager UserManager, sessionRetriever SessionRetriever, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatRoomDeleter ChatRoomDeleter, chatSessionRetriever ChatSessionRetriever, directoryManager DirectoryManager, messageRelayer MessageRelayer, bartAssetManager BARTAssetManager, feedbagRetriever FeedBagRetriever, accountManager AccountManager, profileRetriever ProfileRetriever, webAPIKeyManager WebAPIKeyManager, logger *slog.Logger) *Server {
+func NewManagementAPI(bld config.Build, listener string, userManager UserManager, sessionRetriever SessionRetriever, buddyBroadcaster BuddyBroadcaster, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatRoomDeleter ChatRoomDeleter, chatSessionRetriever ChatSessionRetriever, directoryManager DirectoryManager, messageRelayer MessageRelayer, bartAssetManager BARTAssetManager, feedbagRetriever FeedBagRetriever, feedbagManager FeedbagManager, accountManager AccountManager, profileRetriever ProfileRetriever, webAPIKeyManager WebAPIKeyManager, logger *slog.Logger) *Server {
 	mux := http.NewServeMux()
 
 	// Handlers for '/user' route
@@ -159,6 +161,19 @@ func NewManagementAPI(bld config.Build, listener string, userManager UserManager
 		deleteBARTHandler(w, r, bartAssetManager, logger)
 	})
 
+	// Handlers for '/feedbag/{screen_name}/group' route
+	mux.HandleFunc("GET /feedbag/{screen_name}/group", func(w http.ResponseWriter, r *http.Request) {
+		getFeedbagBuddyHandler(w, r, feedbagManager, logger)
+	})
+
+	// Handlers for '/feedbag/{screen_name}/group/{group_id}/buddy/{buddy_screen_name}' route
+	mux.HandleFunc("PUT /feedbag/{screen_name}/group/{group_id}/buddy/{buddy_screen_name}", func(w http.ResponseWriter, r *http.Request) {
+		putFeedbagBuddyHandler(w, r, buddyBroadcaster, feedbagManager, sessionRetriever, messageRelayer, logger, rand.Intn)
+	})
+	mux.HandleFunc("DELETE /feedbag/{screen_name}/group/{group_id}/buddy/{buddy_screen_name}", func(w http.ResponseWriter, r *http.Request) {
+		deleteFeedbagBuddyHandler(w, r, buddyBroadcaster, feedbagManager, sessionRetriever, messageRelayer, logger)
+	})
+
 	return &Server{
 		server: http.Server{
 			Addr:    listener,
@@ -1206,3 +1221,323 @@ func deleteBARTHandler(w http.ResponseWriter, r *http.Request, bartAssetManager
 	msg := messageBody{Message: "BART asset deleted successfully."}
 	json.NewEncoder(w).Encode(msg)
 }
+
+// getFeedbagBuddyHandler handles the GET /feedbag/{screen_name}/group endpoint.
+func getFeedbagBuddyHandler(w http.ResponseWriter, r *http.Request, feedbagManager FeedbagManager, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "application/json")
+
+	screenName := r.PathValue("screen_name")
+	if screenName == "" {
+		errorMsg(w, "screen_name is required", http.StatusBadRequest)
+		return
+	}
+
+	items, err := feedbagManager.Feedbag(r.Context(), state.NewIdentScreenName(screenName))
+	if err != nil {
+		logger.Error("error retrieving feedbag", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	if len(items) == 0 {
+		errorMsg(w, "feedbag not found", http.StatusNotFound)
+		return
+	}
+
+	buddyMap := make(map[uint16][]*wire.FeedbagItem)
+
+	for _, item := range items {
+		switch item.ClassID {
+		case wire.FeedbagClassIdBuddy:
+			buddyMap[item.GroupID] = append(buddyMap[item.GroupID], &item)
+		}
+	}
+
+	type buddyItem struct {
+		Name   string `json:"name"`
+		ItemID uint16 `json:"item_id"`
+	}
+	type groupItem struct {
+		GroupID   uint16      `json:"group_id"`
+		GroupName string      `json:"group_name"`
+		Buddies   []buddyItem `json:"buddies"`
+	}
+
+	response := make([]groupItem, 0)
+
+	for _, item := range items {
+		switch item.ClassID {
+		case wire.FeedbagClassIdGroup:
+			if item.GroupID == 0 {
+				// can't add buddies to the root group
+				continue
+			}
+			group := groupItem{
+				GroupID:   item.GroupID,
+				GroupName: item.Name,
+				Buddies:   make([]buddyItem, 0, len(buddyMap[item.GroupID])),
+			}
+			for _, buddy := range buddyMap[item.GroupID] {
+				group.Buddies = append(group.Buddies, buddyItem{
+					Name:   buddy.Name,
+					ItemID: buddy.ItemID,
+				})
+			}
+			response = append(response, group)
+		}
+	}
+
+	if err := json.NewEncoder(w).Encode(response); err != nil {
+		logger.Error("error encoding response", "err", err.Error())
+	}
+}
+
+// putFeedbagBuddyHandler handles the PUT /feedbag/{screen_name}/group/{group_id}/buddy/{buddy_screen_name} endpoint.
+func putFeedbagBuddyHandler(w http.ResponseWriter, r *http.Request, buddyBroadcaster BuddyBroadcaster, feedbagManager FeedbagManager, sessionRetriever SessionRetriever, messageRelayer MessageRelayer, logger *slog.Logger, randInt func(n int) int) {
+	w.Header().Set("Content-Type", "application/json")
+
+	gid, err := strconv.ParseUint(r.PathValue("group_id"), 10, 16)
+	if err != nil {
+		errorMsg(w, "invalid group_id", http.StatusBadRequest)
+		return
+	}
+	groupID := uint16(gid)
+
+	if groupID == 0 {
+		errorMsg(w, "can't add buddies to root group", http.StatusBadRequest)
+		return
+	}
+
+	screenName := r.PathValue("screen_name")
+	if screenName == "" {
+		errorMsg(w, "screen_name is required", http.StatusBadRequest)
+		return
+	}
+	me := state.NewIdentScreenName(screenName)
+
+	buddyScreenName := r.PathValue("buddy_screen_name")
+	if buddyScreenName == "" {
+		errorMsg(w, "buddy_screen_name is required", http.StatusBadRequest)
+		return
+	}
+
+	newBuddy := state.NewIdentScreenName(buddyScreenName)
+
+	items, err := feedbagManager.Feedbag(r.Context(), me)
+	if err != nil {
+		logger.Error("error retrieving feedbag", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	var group *wire.FeedbagItem
+
+	for _, item := range items {
+		switch {
+		case item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == groupID:
+			group = &item
+		case item.ClassID == wire.FeedbagClassIdBuddy && item.GroupID == groupID && item.Name == newBuddy.String():
+			response := struct {
+				Name    string `json:"name"`
+				GroupID uint16 `json:"group_id"`
+				ItemID  uint16 `json:"item_id"`
+			}{
+				Name:    buddyScreenName,
+				GroupID: groupID,
+				ItemID:  item.ItemID,
+			}
+			w.WriteHeader(http.StatusOK)
+			if err := json.NewEncoder(w).Encode(response); err != nil {
+				logger.Error("error encoding response", "err", err.Error())
+			}
+			return
+		}
+	}
+
+	if group == nil {
+		errorMsg(w, "group not found", http.StatusNotFound)
+		return
+	}
+
+	buddyItem := wire.FeedbagItem{
+		Name:    buddyScreenName,
+		GroupID: groupID,
+		ItemID:  randItemID(randInt, items),
+		ClassID: wire.FeedbagClassIdBuddy,
+	}
+
+	if buddyItem.ItemID == 0 {
+		errorMsg(w, "maximum items reached", http.StatusConflict)
+		return
+	}
+
+	if order, hasOrder := group.Bytes(wire.FeedbagAttributesOrder); hasOrder {
+		var memberIDs []uint16
+		if err := wire.UnmarshalBE(&memberIDs, bytes.NewReader(order)); err != nil {
+			logger.Error("error decoding order TLV", "err", err.Error())
+			errorMsg(w, "internal server error", http.StatusInternalServerError)
+			return
+		}
+		group.Replace(wire.NewTLVBE(wire.FeedbagAttributesOrder, append(memberIDs, buddyItem.ItemID)))
+	} else {
+		group.Append(wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{buddyItem.ItemID}))
+	}
+
+	updates := []wire.FeedbagItem{
+		buddyItem,
+		*group,
+	}
+	if err := feedbagManager.FeedbagUpsert(r.Context(), me, updates); err != nil {
+		logger.Error("error inserting feedbag item", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	session := sessionRetriever.RetrieveSession(me)
+	if session != nil {
+		messageRelayer.RelayToScreenName(r.Context(), me, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagInsertItem,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{buddyItem},
+			},
+		})
+		messageRelayer.RelayToScreenName(r.Context(), me, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagUpdateItem,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{*group},
+			},
+		})
+		instances := session.Instances()
+		if len(instances) > 0 {
+			if err := buddyBroadcaster.BroadcastVisibility(r.Context(), instances[0], []state.IdentScreenName{newBuddy}, false); err != nil {
+				logger.Error("error broadcasting visibility", "err", err.Error())
+			}
+		}
+	}
+
+	response := struct {
+		Name    string `json:"name"`
+		GroupID uint16 `json:"group_id"`
+		ItemID  uint16 `json:"item_id"`
+	}{
+		Name:    buddyItem.Name,
+		GroupID: buddyItem.GroupID,
+		ItemID:  buddyItem.ItemID,
+	}
+
+	w.WriteHeader(http.StatusOK)
+	if err := json.NewEncoder(w).Encode(response); err != nil {
+		logger.Error("error encoding response", "err", err.Error())
+	}
+}
+
+func randItemID(randInt func(n int) int, items []wire.FeedbagItem) uint16 {
+	num := uint16(randInt(math.MaxUint16))
+	for itemID := num; itemID != num-1; itemID++ {
+		if itemID == 0 {
+			continue
+		}
+		exists := false
+		for _, item := range items {
+			if item.GroupID == itemID || item.ItemID == itemID {
+				exists = true
+				break
+			}
+		}
+		if !exists {
+			return itemID
+		}
+	}
+	return 0
+}
+
+// deleteFeedbagBuddyHandler handles the DELETE /feedbag/{screen_name}/group/{group_id}/buddy/{buddy_screen_name} endpoint.
+func deleteFeedbagBuddyHandler(w http.ResponseWriter, r *http.Request, buddyBroadcaster BuddyBroadcaster, feedbagManager FeedbagManager, sessionRetriever SessionRetriever, messageRelayer MessageRelayer, logger *slog.Logger) {
+	gid, err := strconv.ParseUint(r.PathValue("group_id"), 10, 16)
+	if err != nil {
+		errorMsg(w, "invalid group_id", http.StatusBadRequest)
+		return
+	}
+	groupID := uint16(gid)
+
+	if groupID == 0 {
+		errorMsg(w, "can't add buddies to root group", http.StatusBadRequest)
+		return
+	}
+
+	screenName := r.PathValue("screen_name")
+	if screenName == "" {
+		errorMsg(w, "screen_name is required", http.StatusBadRequest)
+		return
+	}
+	me := state.NewIdentScreenName(screenName)
+
+	buddyScreenName := r.PathValue("buddy_screen_name")
+	if buddyScreenName == "" {
+		errorMsg(w, "buddy_screen_name is required", http.StatusBadRequest)
+		return
+	}
+	deleteBuddy := state.NewIdentScreenName(buddyScreenName)
+
+	items, err := feedbagManager.Feedbag(r.Context(), me)
+	if err != nil {
+		logger.Error("error retrieving feedbag", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	var itemToDelete *wire.FeedbagItem
+	var groupFound bool
+	for _, item := range items {
+		switch {
+		case item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == groupID:
+			groupFound = true
+		case item.ClassID == wire.FeedbagClassIdBuddy && item.Name == buddyScreenName && item.GroupID == groupID:
+			itemToDelete = &item
+		}
+	}
+
+	switch {
+	case !groupFound:
+		errorMsg(w, "group not found", http.StatusNotFound)
+		return
+	case itemToDelete == nil:
+		errorMsg(w, "buddy not found", http.StatusNotFound)
+		return
+	}
+
+	if err := feedbagManager.FeedbagDelete(r.Context(), me, []wire.FeedbagItem{*itemToDelete}); err != nil {
+		logger.Error("error deleting feedbag item", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	session := sessionRetriever.RetrieveSession(me)
+	if session != nil {
+		messageRelayer.RelayToScreenName(r.Context(), me, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagDeleteItem,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+				Items: []wire.FeedbagItem{*itemToDelete},
+			},
+		})
+		instances := session.Instances()
+		if len(instances) > 0 {
+			if err := buddyBroadcaster.BroadcastVisibility(r.Context(), instances[0], []state.IdentScreenName{deleteBuddy}, true); err != nil {
+				logger.Error("error broadcasting visibility", "err", err.Error())
+			}
+		}
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}

+ 1413 - 0
server/http/mgmt_api_test.go

@@ -1,10 +1,12 @@
 package http
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
 	"log/slog"
+	"math"
 	"net/http"
 	"net/http/httptest"
 	"net/mail"
@@ -3047,3 +3049,1414 @@ type errorReader struct{}
 func (er *errorReader) Read(p []byte) (n int, err error) {
 	return 0, errors.New("read error")
 }
+
+func TestFeedbagBuddyHandler_GET(t *testing.T) {
+	tt := []struct {
+		name           string
+		screenName     string
+		wantStatusCode int
+		wantResponse   string
+		mockParams     mockParams
+	}{
+		{
+			name:           "empty feedbag",
+			screenName:     "userA",
+			wantStatusCode: http.StatusNotFound,
+			wantResponse:   `{"message":"feedbag not found"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     []wire.FeedbagItem{},
+							err:        nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "feedbag with buddies in groups",
+			screenName:     "userA",
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `[{"group_id":1,"group_name":"Friends","buddies":[{"name":"buddy1","item_id":10},{"name":"buddy2","item_id":11}]},{"group_id":2,"group_name":"Work","buddies":[{"name":"buddy3","item_id":20}]}]`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  0,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "",
+									GroupID: 0,
+								},
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+								{
+									ItemID:  2,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Work",
+									GroupID: 2,
+								},
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+								{
+									ItemID:  11,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy2",
+									GroupID: 1,
+								},
+								{
+									ItemID:  20,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy3",
+									GroupID: 2,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "feedbag with no groups (besides root)",
+			screenName:     "userA",
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `[]`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  0,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "",
+									GroupID: 0,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+			},
+			// Note: Root groups (GroupID == 0) are skipped by the implementation,
+			// so the response will be an empty array []
+		},
+		{
+			name:           "missing screen_name",
+			screenName:     "",
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"screen_name is required"}`,
+		},
+		{
+			name:           "internal server error",
+			screenName:     "userA",
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     nil,
+							err:        errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodGet, "/feedbag/"+tc.screenName+"/group", nil)
+			if tc.screenName != "" {
+				request.SetPathValue("screen_name", tc.screenName)
+			}
+			responseRecorder := httptest.NewRecorder()
+
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+
+			getFeedbagBuddyHandler(responseRecorder, request, feedbagManager, slog.Default())
+
+			assert.Equal(t, tc.wantStatusCode, responseRecorder.Code)
+			assert.JSONEq(t, tc.wantResponse, responseRecorder.Body.String())
+		})
+	}
+}
+
+func TestFeedbagBuddyHandler_PUT(t *testing.T) {
+	tt := []struct {
+		name           string
+		screenName     string
+		groupID        string
+		requestBody    string
+		wantStatusCode int
+		wantResponse   string
+		mockParams     mockParams
+	}{
+		{
+			name:           "add a buddy to an empty group, user not signed in",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"newbuddy"}`,
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `{"name":"newbuddy","group_id":1,"item_id":1000}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									Name:    "newbuddy",
+									GroupID: 1,
+									ItemID:  1000,
+									ClassID: wire.FeedbagClassIdBuddy,
+								},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1000}),
+										},
+									},
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     state.NewSession(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagInsertItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											Name:    "newbuddy",
+											GroupID: 1,
+											ItemID:  1000,
+											ClassID: wire.FeedbagClassIdBuddy,
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagUpdateItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											ClassID: wire.FeedbagClassIdGroup,
+											Name:    "Friends",
+											GroupID: 1,
+											TLVLBlock: wire.TLVLBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1000}),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "add a buddy to a non-empty group, user not signed in",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"newbuddy2"}`,
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `{"name":"newbuddy2","group_id":1,"item_id":1000}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{12345}),
+										},
+									},
+								},
+								{
+									ItemID:  12345,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "existingbuddy",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									Name:    "newbuddy2",
+									GroupID: 1,
+									ItemID:  1000,
+									ClassID: wire.FeedbagClassIdBuddy,
+								},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{12345, 1000}),
+										},
+									},
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     state.NewSession(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagInsertItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											Name:    "newbuddy2",
+											GroupID: 1,
+											ItemID:  1000,
+											ClassID: wire.FeedbagClassIdBuddy,
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagUpdateItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											ClassID: wire.FeedbagClassIdGroup,
+											Name:    "Friends",
+											GroupID: 1,
+											TLVLBlock: wire.TLVLBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{12345, 1000}),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "add a buddy that already exists in a group",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"existingbuddy"}`,
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `{"name":"existingbuddy","group_id":1,"item_id":12345}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{12345}),
+										},
+									},
+								},
+								{
+									ItemID:  12345,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "existingbuddy",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					// No FeedbagUpsert should be called when buddy already exists
+				},
+				// No session retrieval or message relaying should occur
+			},
+		},
+		{
+			name:           "add a buddy to an empty group, user signed in",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"newbuddy"}`,
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `{"name":"newbuddy","group_id":1,"item_id":1000}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									Name:    "newbuddy",
+									GroupID: 1,
+									ItemID:  1000,
+									ClassID: wire.FeedbagClassIdBuddy,
+								},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1000}),
+										},
+									},
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: func() *state.Session {
+								sess := state.NewSession()
+								sess.SetIdentScreenName(state.NewIdentScreenName("userA"))
+								inst := sess.AddInstance()
+								inst.SetSignonComplete()
+								return sess
+							}(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagInsertItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											Name:    "newbuddy",
+											GroupID: 1,
+											ItemID:  1000,
+											ClassID: wire.FeedbagClassIdBuddy,
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagUpdateItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											ClassID: wire.FeedbagClassIdGroup,
+											Name:    "Friends",
+											GroupID: 1,
+											TLVLBlock: wire.TLVLBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1000}),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							you:            nil, // Not used in expectation, matched with mock.AnythingOfType
+							filter:         []state.IdentScreenName{state.NewIdentScreenName("newbuddy")},
+							sendDepartures: false,
+							err:            nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "invalid group_id - non-numeric",
+			screenName:     "userA",
+			groupID:        "invalid",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid group_id"}`,
+		},
+		{
+			name:           "invalid group_id - out of range",
+			screenName:     "userA",
+			groupID:        "99999",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid group_id"}`,
+		},
+		{
+			name:           "can't add buddies to root group",
+			screenName:     "userA",
+			groupID:        "0",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"can't add buddies to root group"}`,
+		},
+		{
+			name:           "empty screen_name",
+			screenName:     "",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"screen_name is required"}`,
+		},
+		{
+			name:           "malformed JSON input",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `invalid json`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"buddy_screen_name is required"}`,
+		},
+		{
+			name:           "empty name in request body",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":""}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"buddy_screen_name is required"}`,
+		},
+		{
+			name:           "missing name in request body",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"buddy_screen_name is required"}`,
+		},
+		{
+			name:           "error retrieving feedbag",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     nil,
+							err:        errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "group not found",
+			screenName:     "userA",
+			groupID:        "999",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusNotFound,
+			wantResponse:   `{"message":"group not found"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+			},
+		},
+
+		{
+			name:           "error inserting feedbag item",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									Name:    "buddy",
+									GroupID: 1,
+									ItemID:  1000,
+									ClassID: wire.FeedbagClassIdBuddy,
+								},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1000}),
+										},
+									},
+								},
+							},
+							err: errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			// Extract buddy name from requestBody JSON
+			var buddyName string
+			if tc.requestBody != "" {
+				var input struct {
+					Name string `json:"name"`
+				}
+				if err := json.Unmarshal([]byte(tc.requestBody), &input); err == nil {
+					buddyName = input.Name
+				}
+			}
+			request := httptest.NewRequest(http.MethodPut, "/feedbag/"+tc.screenName+"/group/"+tc.groupID+"/buddy/"+buddyName, nil)
+			if tc.screenName != "" {
+				request.SetPathValue("screen_name", tc.screenName)
+			}
+			if tc.groupID != "" {
+				request.SetPathValue("group_id", tc.groupID)
+			}
+			request.SetPathValue("buddy_screen_name", buddyName)
+			responseRecorder := httptest.NewRecorder()
+
+			feedbagManager := newMockFeedbagManager(t)
+			sessionRetriever := newMockSessionRetriever(t)
+			messageRelayer := newMockMessageRelayer(t)
+			buddyBroadcaster := newMockBuddyBroadcaster(t)
+
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagUpsertParams {
+				feedbagManager.EXPECT().
+					FeedbagUpsert(matchContext(), params.screenName, params.items).
+					Return(params.err)
+			}
+			for _, params := range tc.mockParams.sessionRetrieverParams.retrieveSessionByNameParams {
+				sessionRetriever.EXPECT().
+					RetrieveSession(params.screenName).
+					Return(params.result)
+			}
+			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().
+					RelayToScreenName(matchContext(), params.screenName, params.msg)
+			}
+			for _, params := range tc.mockParams.buddyBroadcasterParams.broadcastVisibilityParams {
+				// Use mock.MatchedBy to match any SessionInstance, since we're mainly verifying filter and sendDepartures
+				buddyBroadcaster.EXPECT().
+					BroadcastVisibility(
+						matchContext(),
+						mock.AnythingOfType("*state.SessionInstance"),
+						params.filter,
+						params.sendDepartures,
+					).
+					Return(params.err)
+			}
+
+			deterministicItemID := func(n int) int {
+				return 1000
+			}
+			putFeedbagBuddyHandler(responseRecorder, request, buddyBroadcaster, feedbagManager, sessionRetriever, messageRelayer, slog.Default(), deterministicItemID)
+
+			assert.Equal(t, tc.wantStatusCode, responseRecorder.Code)
+			if tc.wantStatusCode == http.StatusOK {
+				assert.JSONEq(t, tc.wantResponse, responseRecorder.Body.String())
+			} else {
+				assert.JSONEq(t, tc.wantResponse, responseRecorder.Body.String())
+			}
+		})
+	}
+}
+
+func TestFeedbagBuddyHandler_DELETE(t *testing.T) {
+	tt := []struct {
+		name           string
+		screenName     string
+		groupID        string
+		requestBody    string
+		wantStatusCode int
+		wantResponse   string
+		mockParams     mockParams
+	}{
+		{
+			name:           "delete existing buddy, user not signed in",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy1"}`,
+			wantStatusCode: http.StatusNoContent,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagDeleteParams: feedbagDeleteParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     state.NewSession(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagDeleteItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+									Items: []wire.FeedbagItem{
+										{
+											ItemID:  10,
+											ClassID: wire.FeedbagClassIdBuddy,
+											Name:    "buddy1",
+											GroupID: 1,
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "delete existing buddy, user signed in",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy1"}`,
+			wantStatusCode: http.StatusNoContent,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagDeleteParams: feedbagDeleteParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: func() *state.Session {
+								sess := state.NewSession()
+								sess.SetIdentScreenName(state.NewIdentScreenName("userA"))
+								inst := sess.AddInstance()
+								inst.SetSignonComplete()
+								return sess
+							}(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagDeleteItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+									Items: []wire.FeedbagItem{
+										{
+											ItemID:  10,
+											ClassID: wire.FeedbagClassIdBuddy,
+											Name:    "buddy1",
+											GroupID: 1,
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							you:            nil, // Not used in expectation, matched with mock.AnythingOfType
+							filter:         []state.IdentScreenName{state.NewIdentScreenName("buddy1")},
+							sendDepartures: true,
+							err:            nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "delete buddy from a group with multiple buddies, user not signed in",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy1"}`,
+			wantStatusCode: http.StatusNoContent,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{10, 20}),
+										},
+									},
+								},
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+								{
+									ItemID:  20,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy2",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagDeleteParams: feedbagDeleteParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     state.NewSession(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagDeleteItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+									Items: []wire.FeedbagItem{
+										{
+											ItemID:  10,
+											ClassID: wire.FeedbagClassIdBuddy,
+											Name:    "buddy1",
+											GroupID: 1,
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "can't delete buddy from root group",
+			screenName:     "userA",
+			groupID:        "0",
+			requestBody:    `{"name":"rootbuddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"can't add buddies to root group"}`,
+		},
+		{
+			name:           "missing screen_name",
+			screenName:     "",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"screen_name is required"}`,
+		},
+		{
+			name:           "group not found",
+			screenName:     "userA",
+			groupID:        "999",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusNotFound,
+			wantResponse:   `{"message":"group not found"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 0,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "invalid group_id",
+			screenName:     "userA",
+			groupID:        "invalid",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid group_id"}`,
+		},
+		{
+			name:           "malformed request body",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `invalid json`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"buddy_screen_name is required"}`,
+		},
+		{
+			name:           "missing name in request body",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"buddy_screen_name is required"}`,
+		},
+		{
+			name:           "buddy not found",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"nonexistent"}`,
+			wantStatusCode: http.StatusNotFound,
+			wantResponse:   `{"message":"buddy not found"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "otherbuddy",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "internal server error on feedbag retrieval",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     nil,
+							err:        errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "internal server error on delete",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy1"}`,
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagDeleteParams: feedbagDeleteParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			// Extract buddy name from requestBody JSON
+			var buddyName string
+			if tc.requestBody != "" {
+				var input struct {
+					Name string `json:"name"`
+				}
+				if err := json.Unmarshal([]byte(tc.requestBody), &input); err == nil {
+					buddyName = input.Name
+				}
+			}
+			request := httptest.NewRequest(http.MethodDelete, "/feedbag/"+tc.screenName+"/group/"+tc.groupID+"/buddy/"+buddyName, nil)
+			if tc.screenName != "" {
+				request.SetPathValue("screen_name", tc.screenName)
+			}
+			if tc.groupID != "" {
+				request.SetPathValue("group_id", tc.groupID)
+			}
+			request.SetPathValue("buddy_screen_name", buddyName)
+			responseRecorder := httptest.NewRecorder()
+
+			feedbagManager := newMockFeedbagManager(t)
+			sessionRetriever := newMockSessionRetriever(t)
+			messageRelayer := newMockMessageRelayer(t)
+			buddyBroadcaster := newMockBuddyBroadcaster(t)
+
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagDeleteParams {
+				feedbagManager.EXPECT().
+					FeedbagDelete(matchContext(), params.screenName, params.items).
+					Return(params.err)
+			}
+			for _, params := range tc.mockParams.sessionRetrieverParams.retrieveSessionByNameParams {
+				sessionRetriever.EXPECT().
+					RetrieveSession(params.screenName).
+					Return(params.result)
+			}
+			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().
+					RelayToScreenName(matchContext(), params.screenName, params.msg)
+			}
+			for _, params := range tc.mockParams.buddyBroadcasterParams.broadcastVisibilityParams {
+				buddyBroadcaster.EXPECT().
+					BroadcastVisibility(
+						matchContext(),
+						mock.AnythingOfType("*state.SessionInstance"),
+						params.filter,
+						params.sendDepartures,
+					).
+					Return(params.err)
+			}
+
+			deleteFeedbagBuddyHandler(responseRecorder, request, buddyBroadcaster, feedbagManager, sessionRetriever, messageRelayer, slog.Default())
+
+			assert.Equal(t, tc.wantStatusCode, responseRecorder.Code)
+			if tc.wantResponse != "" {
+				assert.JSONEq(t, tc.wantResponse, responseRecorder.Body.String())
+			}
+		})
+	}
+}
+
+func TestRandItemID(t *testing.T) {
+	tt := []struct {
+		name        string
+		randInt     func(n int) int
+		items       []wire.FeedbagItem
+		want        uint16
+		description string
+	}{
+		{
+			name: "empty items list returns random ID",
+			randInt: func(n int) int {
+				return 1000
+			},
+			items:       []wire.FeedbagItem{},
+			want:        1000,
+			description: "When no items exist, should return the random number generated",
+		},
+		{
+			name: "finds next available ID when starting ID conflicts with ItemID",
+			randInt: func(n int) int {
+				return 100
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: 100, GroupID: 1},
+				{ItemID: 101, GroupID: 1},
+			},
+			want:        102,
+			description: "Should skip 100 and 101, return 102",
+		},
+		{
+			name: "finds next available ID when starting ID conflicts with GroupID",
+			randInt: func(n int) int {
+				return 50
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: 1, GroupID: 50},
+				{ItemID: 2, GroupID: 51},
+			},
+			want:        52,
+			description: "Should skip 50 (GroupID) and 51 (GroupID), return 52",
+		},
+		{
+			name: "wraps around and skips 0 to find next available ID",
+			randInt: func(n int) int {
+				return math.MaxUint16 - 2
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: math.MaxUint16 - 2, GroupID: 1},
+				{ItemID: math.MaxUint16 - 1, GroupID: 1},
+				{ItemID: math.MaxUint16, GroupID: 1},
+			},
+			want:        2,
+			description: "When wrapping around, skips 0 (always skipped) and 1 (if conflicts), returns 2",
+		},
+		{
+			name: "skips 0 when starting from 0 and finds next available",
+			randInt: func(n int) int {
+				return 0
+			},
+			items:       []wire.FeedbagItem{},
+			want:        1,
+			description: "When starting from 0, skips 0 (always skipped) and returns 1",
+		},
+		{
+			name: "returns 0 when all IDs are taken",
+			randInt: func(n int) int {
+				return 100
+			},
+			items: func() []wire.FeedbagItem {
+				// Create items that cover all possible IDs
+				items := make([]wire.FeedbagItem, 0, math.MaxUint16+1)
+				for i := 0; i <= math.MaxUint16; i++ {
+					items = append(items, wire.FeedbagItem{
+						ItemID:  uint16(i),
+						GroupID: uint16(i),
+					})
+				}
+				return items
+			}(),
+			want:        0,
+			description: "When all IDs are taken, should return 0",
+		},
+		{
+			name: "finds ID that conflicts with both ItemID and GroupID",
+			randInt: func(n int) int {
+				return 200
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: 200, GroupID: 201},
+				{ItemID: 201, GroupID: 200},
+			},
+			want:        202,
+			description: "Should skip 200 (ItemID) and 201 (both ItemID and GroupID), return 202",
+		},
+		{
+			name: "finds available ID immediately when no conflicts",
+			randInt: func(n int) int {
+				return 500
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: 100, GroupID: 1},
+				{ItemID: 200, GroupID: 2},
+				{ItemID: 300, GroupID: 3},
+			},
+			want:        500,
+			description: "When random ID has no conflicts, should return it immediately",
+		},
+		{
+			name: "handles single conflict and finds next",
+			randInt: func(n int) int {
+				return 42
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: 42, GroupID: 1},
+			},
+			want:        43,
+			description: "Should skip 42 and return 43",
+		},
+		{
+			name: "finds ID before starting point when wrapping",
+			randInt: func(n int) int {
+				return 5
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: 5, GroupID: 1},
+				{ItemID: 6, GroupID: 1},
+				{ItemID: 7, GroupID: 1},
+			},
+			want:        8,
+			description: "Should skip 5, 6, 7 and return 8",
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			got := randItemID(tc.randInt, tc.items)
+			if got != tc.want {
+				t.Errorf("randItemID() = %d, want %d. %s", got, tc.want, tc.description)
+			}
+		})
+	}
+}

+ 108 - 0
server/http/mock_buddy_broadcaster_test.go

@@ -0,0 +1,108 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package http
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockBuddyBroadcaster creates a new instance of mockBuddyBroadcaster. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockBuddyBroadcaster(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBuddyBroadcaster {
+	mock := &mockBuddyBroadcaster{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockBuddyBroadcaster is an autogenerated mock type for the BuddyBroadcaster type
+type mockBuddyBroadcaster struct {
+	mock.Mock
+}
+
+type mockBuddyBroadcaster_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBuddyBroadcaster) EXPECT() *mockBuddyBroadcaster_Expecter {
+	return &mockBuddyBroadcaster_Expecter{mock: &_m.Mock}
+}
+
+// BroadcastVisibility provides a mock function for the type mockBuddyBroadcaster
+func (_mock *mockBuddyBroadcaster) BroadcastVisibility(ctx context.Context, you *state.SessionInstance, filter []state.IdentScreenName, sendDepartures bool) error {
+	ret := _mock.Called(ctx, you, filter, sendDepartures)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BroadcastVisibility")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, []state.IdentScreenName, bool) error); ok {
+		r0 = returnFunc(ctx, you, filter, sendDepartures)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockBuddyBroadcaster_BroadcastVisibility_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BroadcastVisibility'
+type mockBuddyBroadcaster_BroadcastVisibility_Call struct {
+	*mock.Call
+}
+
+// BroadcastVisibility is a helper method to define mock.On call
+//   - ctx context.Context
+//   - you *state.SessionInstance
+//   - filter []state.IdentScreenName
+//   - sendDepartures bool
+func (_e *mockBuddyBroadcaster_Expecter) BroadcastVisibility(ctx interface{}, you interface{}, filter interface{}, sendDepartures interface{}) *mockBuddyBroadcaster_BroadcastVisibility_Call {
+	return &mockBuddyBroadcaster_BroadcastVisibility_Call{Call: _e.mock.On("BroadcastVisibility", ctx, you, filter, sendDepartures)}
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastVisibility_Call) Run(run func(ctx context.Context, you *state.SessionInstance, filter []state.IdentScreenName, sendDepartures bool)) *mockBuddyBroadcaster_BroadcastVisibility_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 []state.IdentScreenName
+		if args[2] != nil {
+			arg2 = args[2].([]state.IdentScreenName)
+		}
+		var arg3 bool
+		if args[3] != nil {
+			arg3 = args[3].(bool)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastVisibility_Call) Return(err error) *mockBuddyBroadcaster_BroadcastVisibility_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastVisibility_Call) RunAndReturn(run func(ctx context.Context, you *state.SessionInstance, filter []state.IdentScreenName, sendDepartures bool) error) *mockBuddyBroadcaster_BroadcastVisibility_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 178 - 0
server/http/mock_feedbag_manager_test.go

@@ -0,0 +1,178 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package http
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockFeedbagManager creates a new instance of mockFeedbagManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockFeedbagManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockFeedbagManager {
+	mock := &mockFeedbagManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockFeedbagManager is an autogenerated mock type for the FeedbagManager type
+type mockFeedbagManager struct {
+	mock.Mock
+}
+
+type mockFeedbagManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockFeedbagManager) EXPECT() *mockFeedbagManager_Expecter {
+	return &mockFeedbagManager_Expecter{mock: &_m.Mock}
+}
+
+// Feedbag provides a mock function for the type mockFeedbagManager
+func (_mock *mockFeedbagManager) Feedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error) {
+	ret := _mock.Called(ctx, screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Feedbag")
+	}
+
+	var r0 []wire.FeedbagItem
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) ([]wire.FeedbagItem, error)); ok {
+		return returnFunc(ctx, screenName)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) []wire.FeedbagItem); ok {
+		r0 = returnFunc(ctx, screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]wire.FeedbagItem)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, state.IdentScreenName) error); ok {
+		r1 = returnFunc(ctx, screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockFeedbagManager_Feedbag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Feedbag'
+type mockFeedbagManager_Feedbag_Call struct {
+	*mock.Call
+}
+
+// Feedbag is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+func (_e *mockFeedbagManager_Expecter) Feedbag(ctx interface{}, screenName interface{}) *mockFeedbagManager_Feedbag_Call {
+	return &mockFeedbagManager_Feedbag_Call{Call: _e.mock.On("Feedbag", ctx, screenName)}
+}
+
+func (_c *mockFeedbagManager_Feedbag_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockFeedbagManager_Feedbag_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagManager_Feedbag_Call) Return(result []wire.FeedbagItem, err error) *mockFeedbagManager_Feedbag_Call {
+	_c.Call.Return(result, err)
+	return _c
+}
+
+// FeedbagUpsert provides a mock function for the type mockFeedbagManager
+func (_mock *mockFeedbagManager) FeedbagUpsert(ctx context.Context, screenName state.IdentScreenName, items []wire.FeedbagItem) error {
+	ret := _mock.Called(ctx, screenName, items)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FeedbagUpsert")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, []wire.FeedbagItem) error); ok {
+		return returnFunc(ctx, screenName, items)
+	}
+	if ret.Get(0) != nil {
+		r0 = ret.Get(0).(error)
+	}
+	return r0
+}
+
+// mockFeedbagManager_FeedbagUpsert_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FeedbagUpsert'
+type mockFeedbagManager_FeedbagUpsert_Call struct {
+	*mock.Call
+}
+
+// FeedbagUpsert is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+//   - items []wire.FeedbagItem
+func (_e *mockFeedbagManager_Expecter) FeedbagUpsert(ctx interface{}, screenName interface{}, items interface{}) *mockFeedbagManager_FeedbagUpsert_Call {
+	return &mockFeedbagManager_FeedbagUpsert_Call{Call: _e.mock.On("FeedbagUpsert", ctx, screenName, items)}
+}
+
+func (_c *mockFeedbagManager_FeedbagUpsert_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, items []wire.FeedbagItem)) *mockFeedbagManager_FeedbagUpsert_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].([]wire.FeedbagItem))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagManager_FeedbagUpsert_Call) Return(err error) *mockFeedbagManager_FeedbagUpsert_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+// FeedbagDelete provides a mock function for the type mockFeedbagManager
+func (_mock *mockFeedbagManager) FeedbagDelete(ctx context.Context, screenName state.IdentScreenName, items []wire.FeedbagItem) error {
+	ret := _mock.Called(ctx, screenName, items)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FeedbagDelete")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, []wire.FeedbagItem) error); ok {
+		return returnFunc(ctx, screenName, items)
+	}
+	if ret.Get(0) != nil {
+		r0 = ret.Get(0).(error)
+	}
+	return r0
+}
+
+// mockFeedbagManager_FeedbagDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FeedbagDelete'
+type mockFeedbagManager_FeedbagDelete_Call struct {
+	*mock.Call
+}
+
+// FeedbagDelete is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+//   - items []wire.FeedbagItem
+func (_e *mockFeedbagManager_Expecter) FeedbagDelete(ctx interface{}, screenName interface{}, items interface{}) *mockFeedbagManager_FeedbagDelete_Call {
+	return &mockFeedbagManager_FeedbagDelete_Call{Call: _e.mock.On("FeedbagDelete", ctx, screenName, items)}
+}
+
+func (_c *mockFeedbagManager_FeedbagDelete_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, items []wire.FeedbagItem)) *mockFeedbagManager_FeedbagDelete_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].([]wire.FeedbagItem))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagManager_FeedbagDelete_Call) Return(err error) *mockFeedbagManager_FeedbagDelete_Call {
+	_c.Call.Return(err)
+	return _c
+}

+ 20 - 0
server/http/types.go

@@ -47,6 +47,13 @@ type BARTAssetManager interface {
 	DeleteBARTItem(ctx context.Context, hash []byte) error
 }
 
+// BuddyBroadcaster defines a method for broadcasting presence updates.
+type BuddyBroadcaster interface {
+	// BroadcastVisibility sends presence updates to the specified filter list.
+	// If sendDepartures is true, departure events are sent as well.
+	BroadcastVisibility(ctx context.Context, you *state.SessionInstance, filter []state.IdentScreenName, sendDepartures bool) error
+}
+
 // ChatRoomCreator defines a method for creating a new chat room.
 type ChatRoomCreator interface {
 	// CreateChatRoom creates a new chat room.
@@ -102,6 +109,19 @@ type FeedBagRetriever interface {
 	BuddyIconMetadata(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error)
 }
 
+// FeedbagManager defines methods for managing feedbag (buddy list) entries.
+// This interface matches foodgroup.FeedbagManager and is implemented by state.SQLiteUserStore.
+type FeedbagManager interface {
+	// Feedbag retrieves all feedbag items for a user.
+	Feedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error)
+
+	// FeedbagUpsert inserts or updates feedbag items.
+	FeedbagUpsert(ctx context.Context, screenName state.IdentScreenName, items []wire.FeedbagItem) error
+
+	// FeedbagDelete deletes feedbag items.
+	FeedbagDelete(ctx context.Context, screenName state.IdentScreenName, items []wire.FeedbagItem) error
+}
+
 // MessageRelayer defines a method for sending a SNAC message to a specific screen name.
 type MessageRelayer interface {
 	// RelayToScreenName sends the given SNAC message to the specified screen name.

+ 4 - 2
server/oscar/mock_oservice_service_test.go

@@ -120,8 +120,10 @@ func (_mock *mockOServiceService) ClientVersions(ctx context.Context, instance *
 	var r0 []wire.SNACMessage
 	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x01_0x17_OServiceClientVersions) []wire.SNACMessage); ok {
 		r0 = returnFunc(ctx, instance, inFrame, inBody)
-	} else if ret.Get(0) != nil {
-		r0 = ret.Get(0).([]wire.SNACMessage)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]wire.SNACMessage)
+		}
 	}
 	return r0
 }