mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
feat(cloudflare): add move and batch operation logics with tests
This commit is contained in:
@@ -19,4 +19,7 @@ const (
|
||||
errSyncFailed = "Cloudflare DNS 同步失败"
|
||||
errDeleteRemoteFailed = "删除 Cloudflare DNS 记录失败"
|
||||
errTaskDispatchFailed = "无法投递 Cloudflare 同步任务"
|
||||
errTargetGroupSame = "目标分组不能为当前分组"
|
||||
errTargetGroupInvalid = "目标分组不存在"
|
||||
errNoMembersSelected = "未选择任何成员"
|
||||
)
|
||||
|
||||
@@ -387,6 +387,96 @@ func RemoveMember(ctx context.Context, groupID, memberID uint) error {
|
||||
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.
|
||||
func DeleteGroup(ctx context.Context, groupID uint) error {
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
type MemberItem struct {
|
||||
ID uint `json:"id"`
|
||||
|
||||
Reference in New Issue
Block a user