feat(cloudflare): register move and batch member API routes

This commit is contained in:
ryan
2026-09-26 15:08:32 +08:00
parent 079fa7ee53
commit b1626d068f
6 changed files with 923 additions and 3 deletions
@@ -358,6 +358,88 @@ func SyncMemberHandler(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(&SyncReceipt{TaskID: taskID}))
}
// MoveMemberHandler moves a member to a target group.
// @Summary 移动 Cloudflare 指向成员到其他分组
// @Tags openflare-cloudflare
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "原分组 ID"
// @Param memberId path int true "成员 ID"
// @Param body body cloudflare.MemberMoveInput true "目标分组参数"
// @Success 200 {object} response.Any{data=cloudflare.MemberItem}
// @Failure 400 {object} response.Any
// @Failure 404 {object} response.Any
// @Router /api/v1/d/cloudflare/groups/{id}/members/{memberId}/move [post]
func MoveMemberHandler(c *gin.Context) {
groupID, memberID, ok := memberParams(c)
if !ok {
return
}
var input MemberMoveInput
if !apiutil.BindJSON(c, &input) {
return
}
item, err := MoveMember(c.Request.Context(), groupID, memberID, input.TargetGroupID)
if abortLogic(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(item))
}
// BatchMoveMembersHandler moves multiple members to a target group.
// @Summary 批量移动 Cloudflare 指向成员
// @Tags openflare-cloudflare
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "原分组 ID"
// @Param body body cloudflare.MemberBatchMoveInput true "批量移动参数"
// @Success 200 {object} response.Any
// @Failure 400 {object} response.Any
// @Failure 404 {object} response.Any
// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-move [post]
func BatchMoveMembersHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input MemberBatchMoveInput
if !apiutil.BindJSON(c, &input) {
return
}
if abortLogic(c, BatchMoveMembers(c.Request.Context(), id, input)) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// BatchRemoveMembersHandler removes multiple members.
// @Summary 批量移出 Cloudflare 指向成员
// @Tags openflare-cloudflare
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "分组 ID"
// @Param body body cloudflare.MemberBatchRemoveInput true "批量移出参数"
// @Success 200 {object} response.Any
// @Failure 400 {object} response.Any
// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-remove [post]
func BatchRemoveMembersHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input MemberBatchRemoveInput
if !apiutil.BindJSON(c, &input) {
return
}
if abortLogic(c, BatchRemoveMembers(c.Request.Context(), id, input)) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// ListAvailableDomainsHandler lists ZoneDomains not assigned to another group.
// @Summary 获取可加入 Cloudflare 指向的域名
// @Tags openflare-cloudflare
@@ -4,6 +4,7 @@
package cloudflare
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
@@ -130,3 +131,268 @@ func TestGetGroupHandlerWithMissingNodeStillSucceeds(t *testing.T) {
t.Fatalf("GetGroupHandler body = %s, want group KR", recorder.Body.String())
}
}
func TestRoutersMoveMemberHandler(t *testing.T) {
ctx, memberID := setupCloudflareLogicDB(t)
restoreDispatch := SetDispatchTaskForTest(func(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) {
return "mock-task-id", nil
})
t.Cleanup(restoreDispatch)
fake := &fakeClient{}
restoreClient := SetClientFactoryForTest(func(string) Client { return fake })
t.Cleanup(restoreClient)
member, err := repository.GetCFPointingMemberByID(ctx, memberID)
if err != nil {
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
}
sourceGroupID := member.GroupID
targetGroup := model.CFPointingGroup{
Name: "target-group",
PrimaryNodeID: 1,
ActiveNodeID: 1,
Enabled: true,
}
if err := db.DB(ctx).Create(&targetGroup).Error; err != nil {
t.Fatalf("Create(targetGroup) error = %v", err)
}
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(response.ErrorHandlerMiddleware())
router.POST("/groups/:id/members/:memberId/move", MoveMemberHandler)
t.Run("Success", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/%d/move", sourceGroupID, memberID), strings.NewReader(fmt.Sprintf(`{"target_group_id":%d}`, targetGroup.ID)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusOK)
}
if !strings.Contains(rec.Body.String(), fmt.Sprintf(`"group_id":%d`, targetGroup.ID)) {
t.Fatalf("MoveMemberHandler body = %s, want group_id %d", rec.Body.String(), targetGroup.ID)
}
updated, err := repository.GetCFPointingMemberByID(ctx, memberID)
if err != nil {
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
}
if updated.GroupID != targetGroup.ID {
t.Errorf("updated member GroupID = %d, want %d", updated.GroupID, targetGroup.ID)
}
})
t.Run("InvalidTargetGroupSameAsSource", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/%d/move", targetGroup.ID, memberID), strings.NewReader(fmt.Sprintf(`{"target_group_id":%d}`, targetGroup.ID)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
}
})
t.Run("InvalidTargetGroupNonExistent", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/%d/move", targetGroup.ID, memberID), strings.NewReader(`{"target_group_id":99999}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
}
})
t.Run("InvalidParams", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/groups/abc/members/1/move", strings.NewReader(`{"target_group_id":1}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
}
})
t.Run("InvalidBody", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/%d/move", targetGroup.ID, memberID), strings.NewReader("invalid json"))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
}
})
}
func TestRoutersBatchMoveMembersHandler(t *testing.T) {
ctx, member1ID := setupCloudflareLogicDB(t)
restoreDispatch := SetDispatchTaskForTest(func(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) {
return "mock-task-id", nil
})
t.Cleanup(restoreDispatch)
fake := &fakeClient{}
restoreClient := SetClientFactoryForTest(func(string) Client { return fake })
t.Cleanup(restoreClient)
member1, err := repository.GetCFPointingMemberByID(ctx, member1ID)
if err != nil {
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
}
sourceGroupID := member1.GroupID
targetGroup := model.CFPointingGroup{
Name: "batch-move-target",
PrimaryNodeID: 1,
ActiveNodeID: 1,
Enabled: true,
}
if err := db.DB(ctx).Create(&targetGroup).Error; err != nil {
t.Fatalf("Create(targetGroup) error = %v", err)
}
domain2 := model.ZoneDomain{ZoneID: 1, Domain: "bm2.example.com"}
if err := db.DB(ctx).Create(&domain2).Error; err != nil {
t.Fatalf("Create(domain2) error = %v", err)
}
member2 := model.CFPointingMember{GroupID: sourceGroupID, ZoneDomainID: domain2.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK}
if err := db.DB(ctx).Create(&member2).Error; err != nil {
t.Fatalf("Create(member2) error = %v", err)
}
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(response.ErrorHandlerMiddleware())
router.POST("/groups/:id/members/batch-move", BatchMoveMembersHandler)
t.Run("Success", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-move", sourceGroupID), strings.NewReader(fmt.Sprintf(`{"member_ids":[%d,%d],"target_group_id":%d}`, member1.ID, member2.ID, targetGroup.ID)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("BatchMoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusOK)
}
for _, mid := range []uint{member1.ID, member2.ID} {
m, err := repository.GetCFPointingMemberByID(ctx, mid)
if err != nil {
t.Fatalf("GetCFPointingMemberByID(%d) error = %v", mid, err)
}
if m.GroupID != targetGroup.ID {
t.Errorf("member %d GroupID = %d, want %d", mid, m.GroupID, targetGroup.ID)
}
}
})
t.Run("EmptyMemberIDs", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-move", sourceGroupID), strings.NewReader(fmt.Sprintf(`{"member_ids":[],"target_group_id":%d}`, targetGroup.ID)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("BatchMoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
}
})
t.Run("TargetGroupSame", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-move", targetGroup.ID), strings.NewReader(fmt.Sprintf(`{"member_ids":[%d],"target_group_id":%d}`, member1.ID, targetGroup.ID)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("BatchMoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
}
})
t.Run("InvalidParams", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/groups/xyz/members/batch-move", strings.NewReader(`{"member_ids":[1],"target_group_id":2}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("BatchMoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
}
})
}
func TestRoutersBatchRemoveMembersHandler(t *testing.T) {
ctx, member1ID := setupCloudflareLogicDB(t)
fake := &fakeClient{}
restoreClient := SetClientFactoryForTest(func(string) Client { return fake })
t.Cleanup(restoreClient)
member1, err := repository.GetCFPointingMemberByID(ctx, member1ID)
if err != nil {
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
}
groupID := member1.GroupID
domain2 := model.ZoneDomain{ZoneID: 1, Domain: "br2.example.com"}
if err := db.DB(ctx).Create(&domain2).Error; err != nil {
t.Fatalf("Create(domain2) error = %v", err)
}
member2 := model.CFPointingMember{GroupID: groupID, ZoneDomainID: domain2.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK}
if err := db.DB(ctx).Create(&member2).Error; err != nil {
t.Fatalf("Create(member2) error = %v", err)
}
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(response.ErrorHandlerMiddleware())
router.POST("/groups/:id/members/batch-remove", BatchRemoveMembersHandler)
t.Run("Success", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-remove", groupID), strings.NewReader(fmt.Sprintf(`{"member_ids":[%d,%d]}`, member1.ID, member2.ID)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("BatchRemoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusOK)
}
for _, mid := range []uint{member1.ID, member2.ID} {
_, err := repository.GetCFPointingMemberByID(ctx, mid)
if err == nil {
t.Errorf("member %d should have been deleted, but still found in DB", mid)
}
}
})
t.Run("EmptyMemberIDs", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-remove", groupID), strings.NewReader(`{"member_ids":[]}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("BatchRemoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
}
})
t.Run("InvalidParams", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/groups/invalid/members/batch-remove", strings.NewReader(`{"member_ids":[1]}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("BatchRemoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
}
})
}