Просмотр исходного кода

add endpoint for adding buddy list groups

Mike 1 месяц назад
Родитель
Сommit
c8d1f55ba1
4 измененных файлов с 480 добавлено и 6 удалено
  1. 74 0
      api.yml
  2. 80 6
      server/http/mgmt_api.go
  3. 314 0
      server/http/mgmt_api_test.go
  4. 12 0
      server/http/types.go

+ 74 - 0
api.yml

@@ -1257,6 +1257,80 @@ paths:
               schema:
                 $ref: '#/components/schemas/MessageResponse'
 
+  /feedbag/{screen_name}/group/{group_name}:
+    put:
+      summary: Add a group to a user's feedbag
+      description: |
+        Create a new buddy list group in the user's feedbag. If the feedbag is empty,
+        the root group is created automatically. This operation is idempotent — if a
+        group with the same name already exists, the request succeeds and returns the
+        existing group.
+      parameters:
+        - name: screen_name
+          in: path
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+          schema:
+            type: string
+        - name: group_name
+          in: path
+          description: The name of the group to add.
+          required: true
+          schema:
+            type: string
+          example: "Friends"
+      responses:
+        '200':
+          description: Group already exists. Operation is idempotent.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  group_id:
+                    type: integer
+                    description: The group ID.
+                  group_name:
+                    type: string
+                    description: The name of the group.
+                required:
+                  - group_id
+                  - group_name
+        '201':
+          description: Group created successfully.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  group_id:
+                    type: integer
+                    description: The group ID.
+                  group_name:
+                    type: string
+                    description: The name of the group.
+                required:
+                  - group_id
+                  - group_name
+        '400':
+          description: Bad request. Invalid input data.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '404':
+          description: User 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

+ 80 - 6
server/http/mgmt_api.go

@@ -174,6 +174,11 @@ func NewManagementAPI(bld config.Build, listener string, userManager UserManager
 		getFeedbagBuddyHandler(w, r, feedbagManager, logger)
 	})
 
+	// Handlers for '/feedbag/{screen_name}/group/{group_name}' route
+	mux.HandleFunc("PUT /feedbag/{screen_name}/group/{group_name}", func(w http.ResponseWriter, r *http.Request) {
+		putFeedbagGroupHandler(w, r, feedbagManager, messageRelayer, logger, rand.Intn)
+	})
+
 	// 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)
@@ -1602,9 +1607,8 @@ func getFeedbagBuddyHandler(w http.ResponseWriter, r *http.Request, feedbagManag
 		ItemID uint16 `json:"item_id"`
 	}
 	type groupItem struct {
-		GroupID   uint16      `json:"group_id"`
-		GroupName string      `json:"group_name"`
-		Buddies   []buddyItem `json:"buddies"`
+		feedbagGroupHandle
+		Buddies []buddyItem `json:"buddies"`
 	}
 
 	response := make([]groupItem, 0)
@@ -1617,9 +1621,8 @@ func getFeedbagBuddyHandler(w http.ResponseWriter, r *http.Request, feedbagManag
 				continue
 			}
 			group := groupItem{
-				GroupID:   item.GroupID,
-				GroupName: item.Name,
-				Buddies:   make([]buddyItem, 0, len(buddyMap[item.GroupID])),
+				feedbagGroupHandle: feedbagGroupFromItem(item),
+				Buddies:            make([]buddyItem, 0, len(buddyMap[item.GroupID])),
 			}
 			for _, buddy := range buddyMap[item.GroupID] {
 				group.Buddies = append(group.Buddies, buddyItem{
@@ -1636,6 +1639,77 @@ func getFeedbagBuddyHandler(w http.ResponseWriter, r *http.Request, feedbagManag
 	}
 }
 
+// putFeedbagGroupHandler handles the PUT /feedbag/{screen_name}/group/{group_name} endpoint.
+func putFeedbagGroupHandler(w http.ResponseWriter, r *http.Request, feedbagManager FeedbagManager, messageRelayer MessageRelayer, logger *slog.Logger, intn func(n int) int) {
+	w.Header().Set("Content-Type", "application/json")
+
+	screenName := r.PathValue("screen_name")
+	if screenName == "" {
+		errorMsg(w, "screen_name is required", http.StatusBadRequest)
+		return
+	}
+
+	groupName := r.PathValue("group_name")
+	if groupName == "" {
+		errorMsg(w, "group_name is required", http.StatusBadRequest)
+		return
+	}
+
+	user := state.NewIdentScreenName(screenName)
+
+	items, err := feedbagManager.Feedbag(r.Context(), user)
+	if err != nil {
+		logger.Error("error retrieving feedbag", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	var groupItem wire.FeedbagItem
+	var alreadyExist bool
+
+	for _, item := range items {
+		if item.ClassID == wire.FeedbagClassIdGroup && item.Name == groupName {
+			alreadyExist = true
+			groupItem = item
+			break
+		}
+	}
+
+	if !alreadyExist {
+		fb := state.NewFeedbagList(items, intn)
+		groupItem = fb.AddGroup(groupName)
+		pending := fb.PendingUpdates()
+
+		if len(pending) > 0 {
+			if err := feedbagManager.FeedbagUpsert(r.Context(), user, pending); err != nil {
+				logger.Error("error inserting feedbag item", "err", err.Error())
+				errorMsg(w, "internal server error", http.StatusInternalServerError)
+				return
+			}
+			messageRelayer.RelayToScreenName(r.Context(), user, wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagInsertItem,
+					RequestID: wire.ReqIDFromServer,
+				},
+				Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+					Items: pending,
+				},
+			})
+		}
+	}
+
+	if alreadyExist {
+		w.WriteHeader(http.StatusOK)
+	} else {
+		w.WriteHeader(http.StatusCreated)
+	}
+
+	if err := json.NewEncoder(w).Encode(feedbagGroupFromItem(groupItem)); 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")

+ 314 - 0
server/http/mgmt_api_test.go

@@ -3172,6 +3172,320 @@ func TestFeedbagBuddyHandler_GET(t *testing.T) {
 	}
 }
 
+func TestFeedbagGroupHandler_PUT(t *testing.T) {
+	tt := []struct {
+		name           string
+		screenName     string
+		groupName      string
+		groupIDGen     func(n int) int
+		wantStatusCode int
+		wantResponse   string
+		mockParams     mockParams
+	}{
+		{
+			name:           "add group to empty feedbag",
+			screenName:     "userA",
+			groupName:      "Friends",
+			groupIDGen:     func(n int) int { return 5 },
+			wantStatusCode: http.StatusCreated,
+			wantResponse:   `{"group_id":5,"group_name":"Friends"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     []wire.FeedbagItem{},
+							err:        nil,
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									GroupID: 0,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{5}),
+										},
+									},
+								},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 5,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				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{
+										{
+											ClassID: wire.FeedbagClassIdGroup,
+											GroupID: 0,
+											TLVLBlock: wire.TLVLBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{5}),
+												},
+											},
+										},
+										{
+											ClassID: wire.FeedbagClassIdGroup,
+											Name:    "Friends",
+											GroupID: 5,
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "add group to feedbag with existing root",
+			screenName:     "userA",
+			groupName:      "Coworkers",
+			groupIDGen:     func(n int) int { return 2 },
+			wantStatusCode: http.StatusCreated,
+			wantResponse:   `{"group_id":2,"group_name":"Coworkers"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									GroupID: 0,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1}),
+										},
+									},
+								},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Buddies",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									GroupID: 0,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2}),
+										},
+									},
+								},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Coworkers",
+									GroupID: 2,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				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{
+										{
+											ClassID: wire.FeedbagClassIdGroup,
+											GroupID: 0,
+											TLVLBlock: wire.TLVLBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2}),
+												},
+											},
+										},
+										{
+											ClassID: wire.FeedbagClassIdGroup,
+											Name:    "Coworkers",
+											GroupID: 2,
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "group already exists",
+			screenName:     "userA",
+			groupName:      "Friends",
+			groupIDGen:     func(n int) int { return 99 },
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `{"group_id":1,"group_name":"Friends"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									GroupID: 0,
+								},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "missing screen_name",
+			screenName:     "",
+			groupName:      "Friends",
+			groupIDGen:     func(n int) int { return 5 },
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"screen_name is required"}`,
+		},
+		{
+			name:           "missing group_name",
+			screenName:     "userA",
+			groupName:      "",
+			groupIDGen:     func(n int) int { return 5 },
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"group_name is required"}`,
+		},
+		{
+			name:           "error retrieving feedbag",
+			screenName:     "userA",
+			groupName:      "Friends",
+			groupIDGen:     func(n int) int { return 5 },
+			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:           "error upserting feedbag",
+			screenName:     "userA",
+			groupName:      "Friends",
+			groupIDGen:     func(n int) int { return 5 },
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     []wire.FeedbagItem{},
+							err:        nil,
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									GroupID: 0,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{5}),
+										},
+									},
+								},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 5,
+								},
+							},
+							err: errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodPut, "/feedbag/"+tc.screenName+"/group/"+tc.groupName, nil)
+			if tc.screenName != "" {
+				request.SetPathValue("screen_name", tc.screenName)
+			}
+			if tc.groupName != "" {
+				request.SetPathValue("group_name", tc.groupName)
+			}
+			responseRecorder := httptest.NewRecorder()
+
+			feedbagManager := newMockFeedbagManager(t)
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tc.mockParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+			for _, params := range tc.mockParams.feedbagUpsertParams {
+				feedbagManager.EXPECT().
+					FeedbagUpsert(matchContext(), params.screenName, params.items).
+					Return(params.err)
+			}
+			for _, params := range tc.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().
+					RelayToScreenName(matchContext(), params.screenName, params.msg)
+			}
+
+			putFeedbagGroupHandler(responseRecorder, request, feedbagManager, messageRelayer, slog.Default(), tc.groupIDGen)
+
+			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

+ 12 - 0
server/http/types.go

@@ -303,6 +303,18 @@ type messageBody struct {
 	Message string `json:"message"`
 }
 
+type feedbagGroupHandle struct {
+	GroupID   uint16 `json:"group_id"`
+	GroupName string `json:"group_name"`
+}
+
+func feedbagGroupFromItem(item wire.FeedbagItem) feedbagGroupHandle {
+	return feedbagGroupHandle{
+		GroupID:   item.GroupID,
+		GroupName: item.Name,
+	}
+}
+
 // Web API key management types
 
 type createWebAPIKeyRequest struct {