From b1626d068f39a08260e29522067b657987498476 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 26 Sep 2026 15:08:32 +0800 Subject: [PATCH] feat(cloudflare): register move and batch member API routes --- docs/docs.go | 219 +++++++++++++- docs/swagger.json | 219 +++++++++++++- docs/swagger.yaml | 137 ++++++++- internal/apps/openflare/cloudflare/routers.go | 82 ++++++ .../apps/openflare/cloudflare/routers_test.go | 266 ++++++++++++++++++ .../v1/openflare/register_cloudflare.go | 3 + 6 files changed, 923 insertions(+), 3 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index ec7252b7..dfc63fa3 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -6362,6 +6362,190 @@ const docTemplate = `{ } } }, + "/api/v1/d/cloudflare/groups/{id}/members/batch-move": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "批量移动 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "原分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "批量移动参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.MemberBatchMoveInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members/batch-remove": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "批量移出 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "批量移出参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.MemberBatchRemoveInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/move": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "移动 Cloudflare 指向成员到其他分组", + "parameters": [ + { + "type": "integer", + "description": "原分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "成员 ID", + "name": "memberId", + "in": "path", + "required": true + }, + { + "description": "目标分组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.MemberMoveInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": { "post": { "security": [ @@ -14260,7 +14444,7 @@ const docTemplate = `{ } }, "400": { - "description": "用户名或密码错误、帐号已禁用等", + "description": "用户名或密码错误", "schema": { "$ref": "#/definitions/response.Any" } @@ -15139,6 +15323,31 @@ const docTemplate = `{ } } }, + "cloudflare.MemberBatchMoveInput": { + "type": "object", + "properties": { + "member_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "target_group_id": { + "type": "integer" + } + } + }, + "cloudflare.MemberBatchRemoveInput": { + "type": "object", + "properties": { + "member_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "cloudflare.MemberCreateInput": { "type": "object", "properties": { @@ -15185,6 +15394,14 @@ const docTemplate = `{ } } }, + "cloudflare.MemberMoveInput": { + "type": "object", + "properties": { + "target_group_id": { + "type": "integer" + } + } + }, "cloudflare.MemberUpdateInput": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 798c9a09..737dae8c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -6355,6 +6355,190 @@ } } }, + "/api/v1/d/cloudflare/groups/{id}/members/batch-move": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "批量移动 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "原分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "批量移动参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.MemberBatchMoveInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members/batch-remove": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "批量移出 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "批量移出参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.MemberBatchRemoveInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/move": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "移动 Cloudflare 指向成员到其他分组", + "parameters": [ + { + "type": "integer", + "description": "原分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "成员 ID", + "name": "memberId", + "in": "path", + "required": true + }, + { + "description": "目标分组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.MemberMoveInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": { "post": { "security": [ @@ -14253,7 +14437,7 @@ } }, "400": { - "description": "用户名或密码错误、帐号已禁用等", + "description": "用户名或密码错误", "schema": { "$ref": "#/definitions/response.Any" } @@ -15132,6 +15316,31 @@ } } }, + "cloudflare.MemberBatchMoveInput": { + "type": "object", + "properties": { + "member_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "target_group_id": { + "type": "integer" + } + } + }, + "cloudflare.MemberBatchRemoveInput": { + "type": "object", + "properties": { + "member_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "cloudflare.MemberCreateInput": { "type": "object", "properties": { @@ -15178,6 +15387,14 @@ } } }, + "cloudflare.MemberMoveInput": { + "type": "object", + "properties": { + "target_group_id": { + "type": "integer" + } + } + }, "cloudflare.MemberUpdateInput": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index def997e1..e8a039aa 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -354,6 +354,22 @@ definitions: updated_at: type: string type: object + cloudflare.MemberBatchMoveInput: + properties: + member_ids: + items: + type: integer + type: array + target_group_id: + type: integer + type: object + cloudflare.MemberBatchRemoveInput: + properties: + member_ids: + items: + type: integer + type: array + type: object cloudflare.MemberCreateInput: properties: proxied: @@ -384,6 +400,11 @@ definitions: zone_id: type: integer type: object + cloudflare.MemberMoveInput: + properties: + target_group_id: + type: integer + type: object cloudflare.MemberUpdateInput: properties: proxied: @@ -8135,6 +8156,52 @@ paths: summary: 添加 Cloudflare 指向成员 tags: - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/members/{memberId}/move: + post: + consumes: + - application/json + parameters: + - description: 原分组 ID + in: path + name: id + required: true + type: integer + - description: 成员 ID + in: path + name: memberId + required: true + type: integer + - description: 目标分组参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/cloudflare.MemberMoveInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.MemberItem' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 移动 Cloudflare 指向成员到其他分组 + tags: + - openflare-cloudflare /api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove: post: parameters: @@ -8228,6 +8295,74 @@ paths: summary: 更新 Cloudflare 指向成员 tags: - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/members/batch-move: + post: + consumes: + - application/json + parameters: + - description: 原分组 ID + in: path + name: id + required: true + type: integer + - description: 批量移动参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/cloudflare.MemberBatchMoveInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 批量移动 Cloudflare 指向成员 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/members/batch-remove: + post: + consumes: + - application/json + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + - description: 批量移出参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/cloudflare.MemberBatchRemoveInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 批量移出 Cloudflare 指向成员 + tags: + - openflare-cloudflare /api/v1/d/cloudflare/groups/{id}/sync: post: parameters: @@ -12869,7 +13004,7 @@ paths: $ref: '#/definitions/oauth.BasicUserInfo' type: object "400": - description: 用户名或密码错误、帐号已禁用等 + description: 用户名或密码错误 schema: $ref: '#/definitions/response.Any' "500": diff --git a/internal/apps/openflare/cloudflare/routers.go b/internal/apps/openflare/cloudflare/routers.go index 08c59c57..617a0599 100644 --- a/internal/apps/openflare/cloudflare/routers.go +++ b/internal/apps/openflare/cloudflare/routers.go @@ -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 diff --git a/internal/apps/openflare/cloudflare/routers_test.go b/internal/apps/openflare/cloudflare/routers_test.go index f6adc344..d7c0f502 100644 --- a/internal/apps/openflare/cloudflare/routers_test.go +++ b/internal/apps/openflare/cloudflare/routers_test.go @@ -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) + } + }) +} diff --git a/internal/router/v1/openflare/register_cloudflare.go b/internal/router/v1/openflare/register_cloudflare.go index 8cf64b6c..2d3aab0b 100644 --- a/internal/router/v1/openflare/register_cloudflare.go +++ b/internal/router/v1/openflare/register_cloudflare.go @@ -28,6 +28,9 @@ func registerCloudflareRoutes(apiGroup *gin.RouterGroup) { route.GET("/groups/:id/members", cf.ListMembersHandler) route.POST("/groups/:id/members", cf.CreateMemberHandler) route.POST("/groups/:id/members/:memberId/update", cf.UpdateMemberHandler) + route.POST("/groups/:id/members/:memberId/move", cf.MoveMemberHandler) route.POST("/groups/:id/members/:memberId/remove", cf.RemoveMemberHandler) route.POST("/groups/:id/members/:memberId/sync", cf.SyncMemberHandler) + route.POST("/groups/:id/members/batch-move", cf.BatchMoveMembersHandler) + route.POST("/groups/:id/members/batch-remove", cf.BatchRemoveMembersHandler) }