278 lines
7.9 KiB
TypeScript
278 lines
7.9 KiB
TypeScript
/* @vitest-environment happy-dom */
|
|
|
|
/* eslint-disable vue/one-component-per-file */
|
|
|
|
import type { SystemNetworkApi } from '#/api/system/network';
|
|
|
|
import { flushPromises, mount } from '@vue/test-utils';
|
|
import { defineComponent, h } from 'vue';
|
|
|
|
import NetworkDdnsTable, {
|
|
getDdnsRetryDisabledReason,
|
|
} from '@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
api: {
|
|
deleteRecord: vi.fn(),
|
|
getList: vi.fn(),
|
|
getProviderStatus: vi.fn(),
|
|
retryRecord: vi.fn(),
|
|
},
|
|
messageSuccess: vi.fn(),
|
|
modalOpenCreate: vi.fn(),
|
|
modalOpenEdit: vi.fn(),
|
|
tableApi: {
|
|
reload: vi.fn(),
|
|
},
|
|
tableOptions: undefined as any,
|
|
}));
|
|
|
|
vi.mock('antdv-next', () => ({
|
|
message: { success: mocks.messageSuccess },
|
|
Space: defineComponent({
|
|
name: 'MockSpace',
|
|
setup(_, { slots }) {
|
|
return () => h('span', slots.default?.());
|
|
},
|
|
}),
|
|
Tag: defineComponent({
|
|
name: 'MockTag',
|
|
setup(_, { slots }) {
|
|
return () => h('span', slots.default?.());
|
|
},
|
|
}),
|
|
Typography: {
|
|
Text: defineComponent({
|
|
name: 'MockText',
|
|
setup(_, { slots }) {
|
|
return () => h('span', slots.default?.());
|
|
},
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('#/components/ktTable', () => ({
|
|
KtTable: defineComponent({
|
|
name: 'MockKtTable',
|
|
setup(_, { slots }) {
|
|
return () =>
|
|
h('section', { 'data-testid': 'ddns-table' }, [
|
|
slots.headerControls?.(),
|
|
slots.bodyCell?.({
|
|
column: { key: 'identity' },
|
|
record: createDdnsRow(),
|
|
}),
|
|
slots.bodyCell?.({
|
|
column: { key: 'syncStatus' },
|
|
record: createDdnsRow(),
|
|
}),
|
|
]);
|
|
},
|
|
}),
|
|
useKtTable: vi.fn((options) => {
|
|
mocks.tableOptions = options;
|
|
return [vi.fn(), mocks.tableApi];
|
|
}),
|
|
}));
|
|
|
|
vi.mock(
|
|
'@test-source/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal',
|
|
() => ({
|
|
default: defineComponent({
|
|
name: 'MockNetworkDdnsRecordModal',
|
|
setup(_, { expose }) {
|
|
expose({
|
|
openCreate: mocks.modalOpenCreate,
|
|
openEdit: mocks.modalOpenEdit,
|
|
});
|
|
return () => h('div');
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
|
|
vi.mock('#/api/system/network', () => ({
|
|
deleteNetworkDdnsRecord: mocks.api.deleteRecord,
|
|
getNetworkDdnsList: mocks.api.getList,
|
|
getNetworkDdnsProviderStatus: mocks.api.getProviderStatus,
|
|
retryNetworkDdnsRecord: mocks.api.retryRecord,
|
|
}));
|
|
|
|
vi.mock('#/locales', () => ({
|
|
$t: (key: string) => key,
|
|
}));
|
|
|
|
function createDdnsRow(
|
|
overrides: Partial<SystemNetworkApi.DdnsRecord> = {},
|
|
): SystemNetworkApi.DdnsRecord {
|
|
return {
|
|
appliedAddress: '123.45.67.89',
|
|
domain: 'kwitsukasa.top',
|
|
enabled: true,
|
|
fqdn: 'nas.kwitsukasa.top',
|
|
id: '90071992547409930',
|
|
lastSyncedAt: '2026-07-23T08:00:00.000Z',
|
|
name: 'NAS IPv4',
|
|
portForwardId: '90071992547409931',
|
|
recordType: 'A',
|
|
retryCount: 0,
|
|
source: {
|
|
currentAddress: '123.45.67.89',
|
|
eligible: true,
|
|
externalPort: 45_678,
|
|
id: '90071992547409931',
|
|
name: 'NAS UDP',
|
|
protocol: 'udp',
|
|
sourceType: 'port_forward_ipv4',
|
|
},
|
|
sourceAddress: '123.45.67.89',
|
|
sourceType: 'port_forward_ipv4',
|
|
subDomain: 'nas',
|
|
syncStatus: 'synced',
|
|
updateTime: '2026-07-23T08:00:00.000Z',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('network DDNS table', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.api.getProviderStatus.mockResolvedValue({
|
|
configured: true,
|
|
enabled: true,
|
|
provider: 'dnspod',
|
|
});
|
|
mocks.api.deleteRecord.mockResolvedValue({});
|
|
mocks.api.retryRecord.mockResolvedValue({});
|
|
mocks.tableApi.reload.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('uses an independent KtTable with dual-stack address columns', () => {
|
|
const wrapper = mount(NetworkDdnsTable);
|
|
|
|
expect(wrapper.find('[data-testid="ddns-table"]').exists()).toBe(true);
|
|
expect(mocks.tableOptions.immediate).toBe(false);
|
|
expect(mocks.tableOptions.columns.map((item: any) => item.key)).toEqual([
|
|
'identity',
|
|
'source',
|
|
'sourceAddress',
|
|
'appliedAddress',
|
|
'syncStatus',
|
|
'lastSync',
|
|
'updateTime',
|
|
]);
|
|
expect(mocks.tableOptions.tableTitle).toBe('system.network.ddnsTitle');
|
|
});
|
|
|
|
it('shows API-provided FQDN without appending the source external port', () => {
|
|
const wrapper = mount(NetworkDdnsTable);
|
|
|
|
expect(wrapper.text()).toContain('nas.kwitsukasa.top');
|
|
expect(wrapper.text()).not.toContain('45678');
|
|
});
|
|
|
|
it('keeps retry visible but disables it for every unsafe state with an exact reason', () => {
|
|
mount(NetworkDdnsTable);
|
|
const retry = mocks.tableOptions.rowActions.find(
|
|
(item: any) => item.key === 'retry',
|
|
);
|
|
|
|
expect(retry.rowVisible).toBeUndefined();
|
|
expect(retry.disabled(createDdnsRow({ enabled: false }))).toBe(true);
|
|
expect(getDdnsRetryDisabledReason(createDdnsRow({ enabled: false }))).toBe(
|
|
'system.network.ddnsRetryDisabled',
|
|
);
|
|
expect(
|
|
getDdnsRetryDisabledReason(createDdnsRow({ syncStatus: 'syncing' })),
|
|
).toBe('system.network.ddnsSyncInProgress');
|
|
expect(
|
|
getDdnsRetryDisabledReason(
|
|
createDdnsRow({
|
|
source: {
|
|
...createDdnsRow().source,
|
|
disabledReasonCode: 'KEEPER_DISABLED',
|
|
eligible: false,
|
|
},
|
|
}),
|
|
),
|
|
).toContain('KEEPER_DISABLED');
|
|
expect(
|
|
getDdnsRetryDisabledReason(
|
|
createDdnsRow({
|
|
source: { ...createDdnsRow().source, currentAddress: null },
|
|
sourceAddress: null,
|
|
}),
|
|
),
|
|
).toBe('system.network.ddnsSourceAddressMissing');
|
|
});
|
|
|
|
it('uses scoped permissions and explains that delete preserves DNSPod records', () => {
|
|
mount(NetworkDdnsTable);
|
|
|
|
expect(mocks.tableOptions.buttons[0].permissionCodes).toEqual([
|
|
'System:Network:Ddns:Create',
|
|
]);
|
|
expect(
|
|
mocks.tableOptions.rowActions.map((item: any) => [
|
|
item.key,
|
|
item.permissionCodes,
|
|
]),
|
|
).toEqual([
|
|
['edit', ['System:Network:Ddns:Update']],
|
|
['retry', ['System:Network:Ddns:Retry']],
|
|
['delete', ['System:Network:Ddns:Delete']],
|
|
]);
|
|
const deleteAction = mocks.tableOptions.rowActions.find(
|
|
(item: any) => item.key === 'delete',
|
|
);
|
|
expect(deleteAction.confirm(createDdnsRow())).toBe(
|
|
'system.network.ddnsDeleteConfirm',
|
|
);
|
|
});
|
|
|
|
it('opens create/edit modals and refreshes both list and provider status on demand', async () => {
|
|
const wrapper = mount(NetworkDdnsTable);
|
|
const row = createDdnsRow();
|
|
|
|
await mocks.tableOptions.buttons[0].onClick({});
|
|
await mocks.tableOptions.rowActions
|
|
.find((item: any) => item.key === 'edit')
|
|
.onClick(row, {});
|
|
await (wrapper.vm as any).reload();
|
|
await flushPromises();
|
|
|
|
expect(mocks.modalOpenCreate).toHaveBeenCalledOnce();
|
|
expect(mocks.modalOpenEdit).toHaveBeenCalledWith(row);
|
|
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
|
|
expect(mocks.api.getProviderStatus).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('supports IPv6 source and applied addresses without IPv4-specific labels', () => {
|
|
mount(NetworkDdnsTable);
|
|
const row = createDdnsRow({
|
|
appliedAddress: '2409:8a31::1',
|
|
fqdn: 'nas6.kwitsukasa.top',
|
|
portForwardId: null,
|
|
recordType: 'AAAA',
|
|
source: {
|
|
currentAddress: '2409:8a31::1',
|
|
eligible: true,
|
|
id: 'agent-ipv6',
|
|
name: 'Agent IPv6',
|
|
sourceType: 'agent_ipv6',
|
|
},
|
|
sourceAddress: '2409:8a31::1',
|
|
sourceType: 'agent_ipv6',
|
|
subDomain: 'nas6',
|
|
});
|
|
const renderBodyCell = (mocks.tableOptions as any).renderBodyCell;
|
|
|
|
expect(row.source.currentAddress).toBe('2409:8a31::1');
|
|
expect(JSON.stringify(mocks.tableOptions.columns)).not.toMatch(
|
|
/sourceIpv4|appliedIpv4/i,
|
|
);
|
|
expect(renderBodyCell).toBeUndefined();
|
|
});
|
|
});
|