From 11c8e5c7f3bc8c25080d513a2d19e8816c358991 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 26 Sep 2026 15:26:37 +0800 Subject: [PATCH] feat(cloudflare): add domain move and batch operations in group detail page --- .../components/member-move-dialog.tsx | 127 +++++++++ .../cloudflare/groups/[id]/page-client.tsx | 230 ++++++++++++++- .../services/openflare/cloudflare.service.ts | 31 +++ .../messages/fragments/cloudflare.en.json | 27 ++ .../messages/fragments/cloudflare.zh-CN.json | 27 ++ .../cloudflare-group-detail.test.tsx | 263 +++++++++++++++++- .../cloudflare/cloudflare-service.test.ts | 25 ++ 7 files changed, 713 insertions(+), 17 deletions(-) create mode 100644 frontend/app/(main)/cloudflare/components/member-move-dialog.tsx create mode 100644 frontend/messages/fragments/cloudflare.en.json create mode 100644 frontend/messages/fragments/cloudflare.zh-CN.json diff --git a/frontend/app/(main)/cloudflare/components/member-move-dialog.tsx b/frontend/app/(main)/cloudflare/components/member-move-dialog.tsx new file mode 100644 index 00000000..939ffb14 --- /dev/null +++ b/frontend/app/(main)/cloudflare/components/member-move-dialog.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useEffect, useMemo, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { + CloudflareGroup, + CloudflareMember, +} from '@/lib/services/openflare'; + +export interface MemberMoveDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + members: CloudflareMember[]; + groups: CloudflareGroup[]; + currentGroupId: number; + pending: boolean; + onSubmit: (targetGroupId: number) => void; +} + +export function MemberMoveDialog({ + open, + onOpenChange, + members, + groups, + currentGroupId, + pending, + onSubmit, +}: MemberMoveDialogProps) { + const t = useTranslations('cloudflare'); + const tCommon = useTranslations('common'); + const [targetGroupId, setTargetGroupId] = useState(''); + + const availableGroups = useMemo( + () => groups.filter((g) => g.id !== currentGroupId), + [groups, currentGroupId], + ); + + useEffect(() => { + if (!open) return; + setTargetGroupId(''); + }, [open]); + + const title = + members.length === 1 + ? t('moveDialog.titleSingle', { domain: members[0]?.domain ?? '' }) + : t('moveDialog.titleBatch', { count: members.length }); + + const description = + members.length > 0 + ? members.map((m) => m.domain).join(', ') + : t('moveDialog.targetGroupLabel'); + + return ( + + + + {title} + + {description} + + + + {availableGroups.length === 0 ? ( +

+ {t('moveDialog.noOtherGroups')} +

+ ) : ( + + + {t('moveDialog.targetGroupLabel')} + + + + )} +
+ + + + +
+
+ ); +} diff --git a/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx b/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx index d621d967..554cc1ca 100644 --- a/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx +++ b/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ArrowLeft, + ArrowRightLeft, Cloud, Loader2, Plus, @@ -16,6 +17,16 @@ import { usePathname } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -25,6 +36,7 @@ import { CardHeader, CardTitle, } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; import { Switch } from '@/components/ui/switch'; import { Table, @@ -41,10 +53,12 @@ import { cloudflareQueryKey, NodeService, type CloudflareGroupPayload, + type CloudflareMember, } from '@/lib/services/openflare'; import { getErrorMessage } from '../../../websites/components/website-utils'; import { GroupDialog } from '../../components/group-dialog'; import { MemberAddDialog } from '../../components/member-add-dialog'; +import { MemberMoveDialog } from '../../components/member-move-dialog'; function getGroupIdFromPathname(pathname: string | null): number { const match = pathname?.match(/^\/cloudflare\/groups\/([^/]+)$/); @@ -53,6 +67,7 @@ function getGroupIdFromPathname(pathname: string | null): number { export function CloudflareGroupDetailPageClient() { const t = useTranslations('cloudflare'); + const tCommon = useTranslations('common'); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); @@ -63,6 +78,13 @@ export function CloudflareGroupDetailPageClient() { const queryClient = useQueryClient(); const [editOpen, setEditOpen] = useState(false); const [addOpen, setAddOpen] = useState(false); + const [selectedMemberIDs, setSelectedMemberIDs] = useState>( + () => new Set(), + ); + const [moveDialogOpen, setMoveDialogOpen] = useState(false); + const [movingMembers, setMovingMembers] = useState([]); + const [batchRemoveOpen, setBatchRemoveOpen] = useState(false); + const detailQuery = useQuery({ queryKey: [...cloudflareQueryKey, 'groups', groupID], queryFn: () => CloudflareService.getGroup(groupID), @@ -77,6 +99,10 @@ export function CloudflareGroupDetailPageClient() { queryKey: ['openflare', 'nodes'], queryFn: () => NodeService.listNodes(), }); + const groupsQuery = useQuery({ + queryKey: [...cloudflareQueryKey, 'groups'], + queryFn: () => CloudflareService.listGroups(), + }); const invalidate = async () => queryClient.invalidateQueries({ queryKey: cloudflareQueryKey }); @@ -150,12 +176,80 @@ export function CloudflareGroupDetailPageClient() { const removeMutation = useMutation({ mutationFn: (memberID: number) => CloudflareService.removeMember(groupID, memberID), - onSuccess: async () => { + onSuccess: async (_, memberID) => { toast.success(t('memberDeleted')); + setSelectedMemberIDs((prev) => { + const next = new Set(prev); + next.delete(memberID); + return next; + }); await invalidate(); }, onError: (error) => toast.error(getErrorMessage(error)), }); + const moveMutation = useMutation({ + mutationFn: async ({ + targetGroupId, + membersToMove, + }: { + targetGroupId: number; + membersToMove: CloudflareMember[]; + }) => { + if (membersToMove.length === 1) { + return CloudflareService.moveMember( + groupID, + membersToMove[0].id, + targetGroupId, + ); + } + return CloudflareService.batchMoveMembers( + groupID, + membersToMove.map((m) => m.id), + targetGroupId, + ); + }, + onSuccess: async (_, variables) => { + if (variables.membersToMove.length === 1) { + toast.success(t('memberMoved')); + } else { + toast.success( + t('batchMoved', { count: variables.membersToMove.length }), + ); + } + setMoveDialogOpen(false); + setSelectedMemberIDs((prev) => { + const next = new Set(prev); + for (const m of variables.membersToMove) { + next.delete(m.id); + } + return next; + }); + await invalidate(); + }, + onError: (error) => toast.error(getErrorMessage(error)), + }); + const batchRemoveMutation = useMutation({ + mutationFn: (memberIds: number[]) => + CloudflareService.batchRemoveMembers(groupID, memberIds), + onSuccess: async (_, memberIds) => { + toast.success(t('batchRemoved', { count: memberIds.length })); + setBatchRemoveOpen(false); + setSelectedMemberIDs(new Set()); + await invalidate(); + }, + onError: (error) => toast.error(getErrorMessage(error)), + }); + + const handleSingleMove = (member: CloudflareMember) => { + setMovingMembers([member]); + setMoveDialogOpen(true); + }; + + const handleBatchMove = (selectedList: CloudflareMember[]) => { + if (selectedList.length === 0) return; + setMovingMembers(selectedList); + setMoveDialogOpen(true); + }; if (!mounted || detailQuery.isLoading) return ( @@ -174,6 +268,12 @@ export function CloudflareGroupDetailPageClient() { ); const { group, members } = detailQuery.data; + const selectedMembers = members.filter((m) => selectedMemberIDs.has(m.id)); + const allSelected = + members.length > 0 && selectedMembers.length === members.length; + const isIndeterminate = + selectedMembers.length > 0 && selectedMembers.length < members.length; + return (
@@ -246,9 +346,41 @@ export function CloudflareGroupDetailPageClient() { - - {t('members')} - {t('membersDesc')} + +
+ {t('members')} + {t('membersDesc')} +
+ {selectedMembers.length > 0 && ( +
+ + {t('selectedCount', { count: selectedMembers.length })} + + + + +
+ )}
{members.length === 0 ? ( @@ -259,6 +391,27 @@ export function CloudflareGroupDetailPageClient() { + + { + if (checked) { + setSelectedMemberIDs( + new Set(members.map((m) => m.id)), + ); + } else { + setSelectedMemberIDs(new Set()); + } + }} + aria-label='Select all' + /> + {t('columns.domain')} {t('columns.desiredIp')} {t('columns.status')} @@ -270,7 +423,29 @@ export function CloudflareGroupDetailPageClient() { {members.map((member) => ( - + + + { + setSelectedMemberIDs((prev) => { + const next = new Set(prev); + if (checked) { + next.add(member.id); + } else { + next.delete(member.id); + } + return next; + }); + }} + aria-label={`Select ${member.domain}`} + /> +
{member.domain}
{member.last_error ? ( @@ -308,6 +483,14 @@ export function CloudflareGroupDetailPageClient() {
+
); } diff --git a/frontend/lib/services/openflare/cloudflare.service.ts b/frontend/lib/services/openflare/cloudflare.service.ts index 29fa7bbb..910baa05 100644 --- a/frontend/lib/services/openflare/cloudflare.service.ts +++ b/frontend/lib/services/openflare/cloudflare.service.ts @@ -102,4 +102,35 @@ export class CloudflareService extends OpenFlareBaseService { `/groups/${groupId}/members/${memberId}/sync`, ); } + + static moveMember( + groupId: number, + memberId: number, + targetGroupId: number, + ): Promise { + return this.post( + `/groups/${groupId}/members/${memberId}/move`, + { target_group_id: targetGroupId }, + ); + } + + static batchMoveMembers( + groupId: number, + memberIds: number[], + targetGroupId: number, + ): Promise { + return this.post(`/groups/${groupId}/members/batch-move`, { + member_ids: memberIds, + target_group_id: targetGroupId, + }); + } + + static batchRemoveMembers( + groupId: number, + memberIds: number[], + ): Promise { + return this.post(`/groups/${groupId}/members/batch-remove`, { + member_ids: memberIds, + }); + } } diff --git a/frontend/messages/fragments/cloudflare.en.json b/frontend/messages/fragments/cloudflare.en.json new file mode 100644 index 00000000..55084996 --- /dev/null +++ b/frontend/messages/fragments/cloudflare.en.json @@ -0,0 +1,27 @@ +{ + "cloudflare": { + "move": "Move", + "batchMove": "Batch Move", + "batchRemove": "Batch Delete", + "selectedCount": "{count} selected", + "clearSelection": "Clear selection", + "moveDialog": { + "titleSingle": "Move domain \"{domain}\"", + "titleBatch": "Batch move domains ({count} items)", + "targetGroupLabel": "Target pointing group", + "targetGroupPlaceholder": "Select target group", + "noOtherGroups": "No other pointing groups available. Please create one first.", + "submit": "Confirm move", + "moving": "Moving..." + }, + "batchRemoveDialog": { + "title": "Confirm batch domain deletion", + "desc": "Are you sure you want to remove {count} selected domains from the current group? This will also clean up DNS records on Cloudflare.", + "confirm": "Confirm delete", + "removing": "Deleting..." + }, + "memberMoved": "Domain moved successfully", + "batchMoved": "Successfully moved {count} domains", + "batchRemoved": "Successfully deleted {count} domains" + } +} diff --git a/frontend/messages/fragments/cloudflare.zh-CN.json b/frontend/messages/fragments/cloudflare.zh-CN.json new file mode 100644 index 00000000..c42a8d80 --- /dev/null +++ b/frontend/messages/fragments/cloudflare.zh-CN.json @@ -0,0 +1,27 @@ +{ + "cloudflare": { + "move": "移动", + "batchMove": "批量移动", + "batchRemove": "批量删除", + "selectedCount": "已选择 {count} 项", + "clearSelection": "取消选择", + "moveDialog": { + "titleSingle": "移动域名「{domain}」", + "titleBatch": "批量移动域名(共 {count} 项)", + "targetGroupLabel": "目标指向分组", + "targetGroupPlaceholder": "请选择目标分组", + "noOtherGroups": "没有其他可用的指向分组,请先创建分组", + "submit": "确认移动", + "moving": "移动中..." + }, + "batchRemoveDialog": { + "title": "确认批量删除域名", + "desc": "确认从当前分组移出已选中的 {count} 个域名吗?这将同时清理 Cloudflare 上的解析记录。", + "confirm": "确认删除", + "removing": "删除中..." + }, + "memberMoved": "域名移动成功", + "batchMoved": "成功移动 {count} 个域名", + "batchRemoved": "成功删除 {count} 个域名" + } +} diff --git a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx index e849dc73..4004ab33 100644 --- a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx +++ b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx @@ -39,17 +39,74 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => { CloudflareService: { ...actual.CloudflareService, getGroup: vi.fn(), + listGroups: vi.fn(), listAvailableDomains: vi.fn(), updateGroup: vi.fn(), createMember: vi.fn(), updateMember: vi.fn(), syncMember: vi.fn(), removeMember: vi.fn(), + moveMember: vi.fn(), + batchMoveMembers: vi.fn(), + batchRemoveMembers: vi.fn(), }, NodeService: { ...actual.NodeService, listNodes: vi.fn() }, }; }); +const mockGroup = { + id: 7, + name: '生产节点', + primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, + backup_node: null, + active_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, + default_proxied: true, + enabled: true, + member_count: 2, + created_at: '', + updated_at: '', +}; + +const mockTargetGroup = { + id: 8, + name: '备用分组', + primary_node: { id: 2, name: '备用节点', ip: '192.0.2.2' }, + backup_node: null, + active_node: { id: 2, name: '备用节点', ip: '192.0.2.2' }, + default_proxied: false, + enabled: true, + member_count: 0, + created_at: '', + updated_at: '', +}; + +const mockMembers = [ + { + id: 101, + group_id: 7, + zone_domain_id: 1, + domain: 'a.example.com', + zone_id: 10, + proxied: true, + desired_ip: '192.0.2.1', + sync_status: 'ok' as const, + last_error: '', + synced_at: null, + }, + { + id: 102, + group_id: 7, + zone_domain_id: 2, + domain: 'b.example.com', + zone_id: 10, + proxied: false, + desired_ip: '192.0.2.1', + sync_status: 'ok' as const, + last_error: '', + synced_at: null, + }, +]; + function renderPage() { const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } }, @@ -72,25 +129,36 @@ describe('Cloudflare group detail refresh', () => { mockGroupId = '7'; mockParamId = '7'; vi.mocked(CloudflareService.getGroup).mockReset(); + vi.mocked(CloudflareService.listGroups).mockReset(); vi.mocked(CloudflareService.listAvailableDomains).mockReset(); + vi.mocked(CloudflareService.moveMember).mockReset(); + vi.mocked(CloudflareService.batchMoveMembers).mockReset(); + vi.mocked(CloudflareService.batchRemoveMembers).mockReset(); vi.mocked(NodeService.listNodes).mockReset(); + + window.HTMLElement.prototype.hasPointerCapture = vi.fn(() => false); + window.HTMLElement.prototype.setPointerCapture = vi.fn(); + window.HTMLElement.prototype.releasePointerCapture = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + vi.mocked(CloudflareService.getGroup).mockResolvedValue({ - group: { - id: 7, - name: '生产节点', - primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, - backup_node: null, - active_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, - default_proxied: true, - enabled: true, - member_count: 0, - created_at: '', - updated_at: '', - }, + group: mockGroup, members: [], }); + vi.mocked(CloudflareService.listGroups).mockResolvedValue([ + mockGroup, + mockTargetGroup, + ]); vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]); vi.mocked(NodeService.listNodes).mockResolvedValue([]); + vi.mocked(CloudflareService.moveMember).mockResolvedValue({ + ...mockMembers[0], + group_id: 8, + }); + vi.mocked(CloudflareService.batchMoveMembers).mockResolvedValue(undefined); + vi.mocked(CloudflareService.batchRemoveMembers).mockResolvedValue( + undefined, + ); }); afterEach(() => { @@ -141,3 +209,174 @@ describe('Cloudflare group detail refresh', () => { }); }); }); + +describe('Cloudflare group detail move and batch operations', () => { + beforeEach(() => { + mockGroupId = '7'; + mockParamId = '7'; + vi.mocked(CloudflareService.getGroup).mockReset(); + vi.mocked(CloudflareService.listGroups).mockReset(); + vi.mocked(CloudflareService.listAvailableDomains).mockReset(); + vi.mocked(CloudflareService.moveMember).mockReset(); + vi.mocked(CloudflareService.batchMoveMembers).mockReset(); + vi.mocked(CloudflareService.batchRemoveMembers).mockReset(); + vi.mocked(NodeService.listNodes).mockReset(); + + window.HTMLElement.prototype.hasPointerCapture = vi.fn(() => false); + window.HTMLElement.prototype.setPointerCapture = vi.fn(); + window.HTMLElement.prototype.releasePointerCapture = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + + vi.mocked(CloudflareService.getGroup).mockResolvedValue({ + group: mockGroup, + members: mockMembers, + }); + vi.mocked(CloudflareService.listGroups).mockResolvedValue([ + mockGroup, + mockTargetGroup, + ]); + vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]); + vi.mocked(NodeService.listNodes).mockResolvedValue([]); + vi.mocked(CloudflareService.moveMember).mockResolvedValue({ + ...mockMembers[0], + group_id: 8, + }); + vi.mocked(CloudflareService.batchMoveMembers).mockResolvedValue(undefined); + vi.mocked(CloudflareService.batchRemoveMembers).mockResolvedValue( + undefined, + ); + }); + + it('supports selecting all members and toggling selection', async () => { + renderPage(); + + expect(await screen.findByText('a.example.com')).toBeVisible(); + expect(screen.getByText('b.example.com')).toBeVisible(); + + // Initially batch toolbar is not visible + expect(screen.queryByText(/已选择/)).not.toBeInTheDocument(); + + // Select all via header checkbox + const selectAllCheckbox = screen.getByRole('checkbox', { + name: 'Select all', + }); + fireEvent.click(selectAllCheckbox); + + // Batch toolbar should appear with count 2 + expect(await screen.findByText('已选择 2 项')).toBeVisible(); + expect(screen.getByRole('button', { name: '批量移动' })).toBeVisible(); + expect(screen.getByRole('button', { name: '批量删除' })).toBeVisible(); + + // Clear selection + fireEvent.click(screen.getByRole('button', { name: '取消选择' })); + expect(screen.queryByText(/已选择/)).not.toBeInTheDocument(); + }); + + it('opens single move dialog and triggers moveMember', async () => { + renderPage(); + + expect(await screen.findByText('a.example.com')).toBeVisible(); + + // Click single move button on first row + const moveButtons = screen.getAllByRole('button', { name: '移动' }); + fireEvent.click(moveButtons[0]); + + // Move dialog should show single member title + expect( + await screen.findByRole('heading', { + name: '移动域名「a.example.com」', + }), + ).toBeVisible(); + + // Open target group select + const selectTrigger = screen.getByRole('combobox'); + fireEvent.keyDown(selectTrigger, { key: 'ArrowDown' }); + + // Select target group + const targetOption = await screen.findByRole('option', { + name: /备用分组/, + }); + fireEvent.click(targetOption); + + // Submit move + fireEvent.click(screen.getByRole('button', { name: '确认移动' })); + + await waitFor(() => { + expect(CloudflareService.moveMember).toHaveBeenCalledWith(7, 101, 8); + }); + }); + + it('opens batch move dialog and triggers batchMoveMembers', async () => { + renderPage(); + + expect(await screen.findByText('a.example.com')).toBeVisible(); + + // Select all members + fireEvent.click(screen.getByRole('checkbox', { name: 'Select all' })); + expect(await screen.findByText('已选择 2 项')).toBeVisible(); + + // Click batch move button + fireEvent.click(screen.getByRole('button', { name: '批量移动' })); + + // Dialog title should show batch title + expect( + await screen.findByRole('heading', { + name: '批量移动域名(共 2 项)', + }), + ).toBeVisible(); + + // Open target group select + const selectTrigger = screen.getByRole('combobox'); + fireEvent.keyDown(selectTrigger, { key: 'ArrowDown' }); + + // Select target group + const targetOption = await screen.findByRole('option', { + name: /备用分组/, + }); + fireEvent.click(targetOption); + + // Submit move + fireEvent.click(screen.getByRole('button', { name: '确认移动' })); + + await waitFor(() => { + expect(CloudflareService.batchMoveMembers).toHaveBeenCalledWith( + 7, + [101, 102], + 8, + ); + }); + }); + + it('opens batch remove dialog and triggers batchRemoveMembers', async () => { + renderPage(); + + expect(await screen.findByText('a.example.com')).toBeVisible(); + + // Select first member only + fireEvent.click( + screen.getByRole('checkbox', { name: 'Select a.example.com' }), + ); + expect(await screen.findByText('已选择 1 项')).toBeVisible(); + + // Click batch remove + fireEvent.click(screen.getByRole('button', { name: '批量删除' })); + + // Confirmation dialog appears + expect( + await screen.findByRole('heading', { name: '确认批量删除域名' }), + ).toBeVisible(); + expect( + screen.getByText(/确认从当前分组移出已选中的 1 个域名吗?/), + ).toBeVisible(); + + // Confirm deletion + fireEvent.click(screen.getByRole('button', { name: '确认删除' })); + + await waitFor(() => { + expect(CloudflareService.batchRemoveMembers).toHaveBeenCalledWith( + 7, + [101], + ); + }); + }); +}); diff --git a/frontend/tests/cloudflare/cloudflare-service.test.ts b/frontend/tests/cloudflare/cloudflare-service.test.ts index 82e26bf7..a95425ad 100644 --- a/frontend/tests/cloudflare/cloudflare-service.test.ts +++ b/frontend/tests/cloudflare/cloudflare-service.test.ts @@ -55,4 +55,29 @@ describe('CloudflareService', () => { undefined, ); }); + + it('calls move and batch member endpoints with correct paths and payloads', async () => { + vi.mocked(apiClient.post).mockResolvedValue(response({})); + + await CloudflareService.moveMember(7, 9, 8); + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/d/cloudflare/groups/7/members/9/move', + { target_group_id: 8 }, + undefined, + ); + + await CloudflareService.batchMoveMembers(7, [9, 10], 8); + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/d/cloudflare/groups/7/members/batch-move', + { member_ids: [9, 10], target_group_id: 8 }, + undefined, + ); + + await CloudflareService.batchRemoveMembers(7, [9, 10]); + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/d/cloudflare/groups/7/members/batch-remove', + { member_ids: [9, 10] }, + undefined, + ); + }); });