feat(cloudflare): add move and batch operation logics with tests

This commit is contained in:
ryan
2026-09-26 14:57:16 +08:00
parent 3867841a2f
commit 079fa7ee53
4 changed files with 302 additions and 0 deletions
@@ -19,4 +19,7 @@ const (
errSyncFailed = "Cloudflare DNS 同步失败" errSyncFailed = "Cloudflare DNS 同步失败"
errDeleteRemoteFailed = "删除 Cloudflare DNS 记录失败" errDeleteRemoteFailed = "删除 Cloudflare DNS 记录失败"
errTaskDispatchFailed = "无法投递 Cloudflare 同步任务" errTaskDispatchFailed = "无法投递 Cloudflare 同步任务"
errTargetGroupSame = "目标分组不能为当前分组"
errTargetGroupInvalid = "目标分组不存在"
errNoMembersSelected = "未选择任何成员"
) )
@@ -387,6 +387,96 @@ func RemoveMember(ctx context.Context, groupID, memberID uint) error {
return repository.DeleteCFPointingMember(ctx, member) return repository.DeleteCFPointingMember(ctx, member)
} }
// MoveMember transfers a member from sourceGroupID to targetGroupID.
func MoveMember(ctx context.Context, sourceGroupID, memberID, targetGroupID uint) (*MemberItem, error) {
if targetGroupID == 0 || targetGroupID == sourceGroupID {
return nil, errors.New(errTargetGroupSame)
}
targetGroup, err := repository.GetCFPointingGroup(ctx, targetGroupID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(errTargetGroupInvalid)
}
return nil, err
}
member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
if err != nil {
return nil, err
}
member.GroupID = targetGroupID
member.SyncStatus = model.CFMemberSyncPending
member.LastError = ""
if err = repository.SaveCFPointingMember(ctx, member); err != nil {
return nil, err
}
if targetGroup.Enabled {
if _, err = DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); err != nil {
logger.WarnF(ctx, "[Cloudflare] dispatch move sync failed: member_id=%d error=%v", member.ID, err)
}
}
domain, err := repository.GetZoneDomainByID(ctx, member.ZoneDomainID)
if err != nil {
return nil, err
}
return memberItem(member, domain), nil
}
// BatchMoveMembers transfers multiple members from sourceGroupID to targetGroupID.
func BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchMoveInput) error {
if len(input.MemberIDs) == 0 {
return errors.New(errNoMembersSelected)
}
if input.TargetGroupID == 0 || input.TargetGroupID == sourceGroupID {
return errors.New(errTargetGroupSame)
}
targetGroup, err := repository.GetCFPointingGroup(ctx, input.TargetGroupID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(errTargetGroupInvalid)
}
return err
}
for _, memberID := range input.MemberIDs {
member, getErr := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
if getErr != nil {
continue
}
member.GroupID = input.TargetGroupID
member.SyncStatus = model.CFMemberSyncPending
member.LastError = ""
if saveErr := repository.SaveCFPointingMember(ctx, member); saveErr != nil {
logger.ErrorF(ctx, "[Cloudflare] batch move save member failed: member_id=%d error=%v", memberID, saveErr)
continue
}
if targetGroup.Enabled {
if _, syncErr := DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); syncErr != nil {
logger.WarnF(ctx, "[Cloudflare] dispatch batch move sync failed: member_id=%d error=%v", member.ID, syncErr)
}
}
}
return nil
}
// BatchRemoveMembers deletes multiple members and their remote A records.
func BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error {
if len(input.MemberIDs) == 0 {
return errors.New(errNoMembersSelected)
}
for _, memberID := range input.MemberIDs {
member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
if err != nil {
continue
}
if delErr := DeleteManagedRecord(ctx, member.ID); delErr != nil {
logger.WarnF(ctx, "[Cloudflare] delete remote record failed during batch remove: member_id=%d error=%v", member.ID, delErr)
}
if err = repository.DeleteCFPointingMember(ctx, member); err != nil {
logger.ErrorF(ctx, "[Cloudflare] delete member failed during batch remove: member_id=%d error=%v", member.ID, err)
}
}
return nil
}
// DeleteGroup removes every managed remote A record and then local state. // DeleteGroup removes every managed remote A record and then local state.
func DeleteGroup(ctx context.Context, groupID uint) error { func DeleteGroup(ctx context.Context, groupID uint) error {
if _, err := repository.GetCFPointingGroup(ctx, groupID); err != nil { if _, err := repository.GetCFPointingGroup(ctx, groupID); err != nil {
@@ -0,0 +1,193 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cloudflare
import (
"context"
"testing"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
)
func TestMoveMemberAndBatchOperations(t *testing.T) {
ctx, member1ID := setupCloudflareLogicDB(t)
var dispatchedTasks []string
restoreDispatch := SetDispatchTaskForTest(func(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) {
dispatchedTasks = append(dispatchedTasks, triggeredBy)
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
// Create target group
targetGroup := model.CFPointingGroup{
Name: "secondary",
PrimaryNodeID: 1,
ActiveNodeID: 1,
DefaultProxied: true,
Enabled: true,
}
if err := db.DB(ctx).Create(&targetGroup).Error; err != nil {
t.Fatalf("Create(targetGroup) error = %v", err)
}
// 1. Target group equals source group -> errTargetGroupSame
t.Run("MoveMember target equals source", func(t *testing.T) {
_, err := MoveMember(ctx, sourceGroupID, member1.ID, sourceGroupID)
if err == nil || err.Error() != errTargetGroupSame {
t.Fatalf("MoveMember() error = %v, want %s", err, errTargetGroupSame)
}
})
// 2. Target group does not exist -> errTargetGroupInvalid
t.Run("MoveMember target group invalid", func(t *testing.T) {
_, err := MoveMember(ctx, sourceGroupID, member1.ID, 99999)
if err == nil || err.Error() != errTargetGroupInvalid {
t.Fatalf("MoveMember() error = %v, want %s", err, errTargetGroupInvalid)
}
})
// 3. Successfully move member to target group
t.Run("MoveMember success", func(t *testing.T) {
item, err := MoveMember(ctx, sourceGroupID, member1.ID, targetGroup.ID)
if err != nil {
t.Fatalf("MoveMember() error = %v", err)
}
if item == nil || item.GroupID != targetGroup.ID {
t.Fatalf("MoveMember() returned item group ID = %v, want %d", item, targetGroup.ID)
}
updated, err := repository.GetCFPointingMemberByID(ctx, member1.ID)
if err != nil {
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
}
if updated.GroupID != targetGroup.ID {
t.Errorf("member GroupID = %d, want %d", updated.GroupID, targetGroup.ID)
}
if updated.SyncStatus != model.CFMemberSyncPending {
t.Errorf("member SyncStatus = %s, want %s", updated.SyncStatus, model.CFMemberSyncPending)
}
if updated.LastError != "" {
t.Errorf("member LastError = %q, want empty", updated.LastError)
}
})
// 4. Batch move multiple members
t.Run("BatchMoveMembers", func(t *testing.T) {
// Test empty members error
emptyErr := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{MemberIDs: nil, TargetGroupID: targetGroup.ID})
if emptyErr == nil || emptyErr.Error() != errNoMembersSelected {
t.Fatalf("BatchMoveMembers() empty error = %v, want %s", emptyErr, errNoMembersSelected)
}
// Test target equals source error
sameErr := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{MemberIDs: []uint{1}, TargetGroupID: sourceGroupID})
if sameErr == nil || sameErr.Error() != errTargetGroupSame {
t.Fatalf("BatchMoveMembers() same group error = %v, want %s", sameErr, errTargetGroupSame)
}
// Test invalid target group error
invalidErr := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{MemberIDs: []uint{1}, TargetGroupID: 99999})
if invalidErr == nil || invalidErr.Error() != errTargetGroupInvalid {
t.Fatalf("BatchMoveMembers() invalid group error = %v, want %s", invalidErr, errTargetGroupInvalid)
}
// Create 2 additional members in sourceGroup
domain2 := model.ZoneDomain{ZoneID: 1, Domain: "test2.example.com"}
domain3 := model.ZoneDomain{ZoneID: 1, Domain: "test3.example.com"}
if err := db.DB(ctx).Create(&domain2).Error; err != nil {
t.Fatalf("Create(domain2) error = %v", err)
}
if err := db.DB(ctx).Create(&domain3).Error; err != nil {
t.Fatalf("Create(domain3) error = %v", err)
}
member2 := model.CFPointingMember{GroupID: sourceGroupID, ZoneDomainID: domain2.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK}
member3 := model.CFPointingMember{GroupID: sourceGroupID, ZoneDomainID: domain3.ID, Proxied: true, SyncStatus: model.CFMemberSyncOK}
if err := db.DB(ctx).Create(&member2).Error; err != nil {
t.Fatalf("Create(member2) error = %v", err)
}
if err := db.DB(ctx).Create(&member3).Error; err != nil {
t.Fatalf("Create(member3) error = %v", err)
}
// Perform batch move
if err := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{
MemberIDs: []uint{member2.ID, member3.ID},
TargetGroupID: targetGroup.ID,
}); err != nil {
t.Fatalf("BatchMoveMembers() error = %v", err)
}
// Verify updated in DB
for _, mid := range []uint{member2.ID, member3.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)
}
if m.SyncStatus != model.CFMemberSyncPending {
t.Errorf("member %d SyncStatus = %s, want %s", mid, m.SyncStatus, model.CFMemberSyncPending)
}
if m.LastError != "" {
t.Errorf("member %d LastError = %q, want empty", mid, m.LastError)
}
}
})
// 5. Batch remove members
t.Run("BatchRemoveMembers", func(t *testing.T) {
// Test empty members error
emptyErr := BatchRemoveMembers(ctx, targetGroup.ID, MemberBatchRemoveInput{MemberIDs: nil})
if emptyErr == nil || emptyErr.Error() != errNoMembersSelected {
t.Fatalf("BatchRemoveMembers() empty error = %v, want %s", emptyErr, errNoMembersSelected)
}
// Create members to remove
domain4 := model.ZoneDomain{ZoneID: 1, Domain: "test4.example.com"}
domain5 := model.ZoneDomain{ZoneID: 1, Domain: "test5.example.com"}
if err := db.DB(ctx).Create(&domain4).Error; err != nil {
t.Fatalf("Create(domain4) error = %v", err)
}
if err := db.DB(ctx).Create(&domain5).Error; err != nil {
t.Fatalf("Create(domain5) error = %v", err)
}
member4 := model.CFPointingMember{GroupID: targetGroup.ID, ZoneDomainID: domain4.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK}
member5 := model.CFPointingMember{GroupID: targetGroup.ID, ZoneDomainID: domain5.ID, Proxied: true, SyncStatus: model.CFMemberSyncOK}
if err := db.DB(ctx).Create(&member4).Error; err != nil {
t.Fatalf("Create(member4) error = %v", err)
}
if err := db.DB(ctx).Create(&member5).Error; err != nil {
t.Fatalf("Create(member5) error = %v", err)
}
// Perform batch remove
if err := BatchRemoveMembers(ctx, targetGroup.ID, MemberBatchRemoveInput{
MemberIDs: []uint{member4.ID, member5.ID},
}); err != nil {
t.Fatalf("BatchRemoveMembers() error = %v", err)
}
// Verify deleted from DB
for _, mid := range []uint{member4.ID, member5.ID} {
_, err := repository.GetCFPointingMemberByID(ctx, mid)
if err == nil {
t.Errorf("GetCFPointingMemberByID(%d) should be deleted, but found", mid)
}
}
})
}
@@ -63,6 +63,22 @@ type MemberUpdateInput struct {
Proxied bool `json:"proxied"` Proxied bool `json:"proxied"`
} }
// MemberMoveInput contains the target group ID for moving a member.
type MemberMoveInput struct {
TargetGroupID uint `json:"target_group_id"`
}
// MemberBatchMoveInput contains the member IDs and target group ID for batch moving.
type MemberBatchMoveInput struct {
MemberIDs []uint `json:"member_ids"`
TargetGroupID uint `json:"target_group_id"`
}
// MemberBatchRemoveInput contains the member IDs for batch deletion.
type MemberBatchRemoveInput struct {
MemberIDs []uint `json:"member_ids"`
}
// MemberItem is the admin-facing member state. // MemberItem is the admin-facing member state.
type MemberItem struct { type MemberItem struct {
ID uint `json:"id"` ID uint `json:"id"`