feat(cloudflare): add domain move and batch operations in group detail page

This commit is contained in:
ryan
2026-09-26 15:26:37 +08:00
parent 4d52a5b097
commit 11c8e5c7f3
7 changed files with 713 additions and 17 deletions
@@ -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],
);
});
});
});
@@ -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,
);
});
});