diff --git a/src/components/workflow/__tests__/FetchTorrentsAction.spec.ts b/src/components/workflow/__tests__/FetchTorrentsAction.spec.ts new file mode 100644 index 00000000..3b0e221e --- /dev/null +++ b/src/components/workflow/__tests__/FetchTorrentsAction.spec.ts @@ -0,0 +1,58 @@ +import FetchTorrentsAction from '@/components/workflow/FetchTorrentsAction.vue' +import { waitFor } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getSelectItems, workflowActionStubs } from './workflowActionTestUtils' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ + get: (...args: unknown[]) => mocks.apiGet(...args), + }), +})) + +async function renderAction() { + return renderWithProviders(FetchTorrentsAction, { + props: { + id: 'fetch-torrents', + data: { search_type: 'media', sites: [] }, + }, + global: { stubs: workflowActionStubs }, + }) +} + +describe('FetchTorrentsAction', () => { + beforeEach(() => { + mocks.apiGet.mockReset() + }) + + it('keeps active sites and maps their names and ids to options', async () => { + mocks.apiGet.mockResolvedValue({ + success: true, + message: '', + data: [ + { id: 1, name: '启用站点', is_active: true }, + { id: 2, name: '停用站点', is_active: false }, + ], + }) + + const { container } = await renderAction() + + await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('site/rss')) + expect(getSelectItems(container, '站点')).toEqual([{ title: '启用站点', value: 1 }]) + }) + + it('keeps site options empty when loading fails', async () => { + const error = new Error('site list unavailable') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + mocks.apiGet.mockRejectedValue(error) + + const { container } = await renderAction() + + await waitFor(() => expect(consoleError).toHaveBeenCalled()) + expect(getSelectItems(container, '站点')).toEqual([]) + }) +}) diff --git a/src/components/workflow/__tests__/FilterTorrentsAction.spec.ts b/src/components/workflow/__tests__/FilterTorrentsAction.spec.ts new file mode 100644 index 00000000..9a6fac82 --- /dev/null +++ b/src/components/workflow/__tests__/FilterTorrentsAction.spec.ts @@ -0,0 +1,65 @@ +import FilterTorrentsAction from '@/components/workflow/FilterTorrentsAction.vue' +import { waitFor } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getSelectItems, workflowActionStubs } from './workflowActionTestUtils' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ + get: (...args: unknown[]) => mocks.apiGet(...args), + }), +})) + +async function renderAction(initialState: Record> = {}) { + return renderWithProviders(FilterTorrentsAction, { + props: { + id: 'filter-torrents', + data: { rule_groups: [] }, + }, + initialState, + global: { stubs: workflowActionStubs }, + }) +} + +describe('FilterTorrentsAction', () => { + beforeEach(() => { + mocks.apiGet.mockReset() + }) + + it('does not request admin filter groups for regular users', async () => { + const { container } = await renderAction() + + expect(mocks.apiGet).not.toHaveBeenCalled() + expect(getSelectItems(container, '过滤规则组')).toEqual([]) + }) + + it('maps unwrapped admin filter groups to name options', async () => { + mocks.apiGet.mockResolvedValue({ + success: true, + message: '', + data: { value: [{ name: '高清规则' }, { name: '字幕规则' }] }, + }) + + const { container } = await renderAction({ user: { superUser: true } }) + + await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('system/setting/UserFilterRuleGroups')) + expect(getSelectItems(container, '过滤规则组')).toEqual([ + { title: '高清规则', value: '高清规则' }, + { title: '字幕规则', value: '字幕规则' }, + ]) + }) + + it('keeps filter group options empty when loading fails', async () => { + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) + mocks.apiGet.mockRejectedValue(new Error('rule groups unavailable')) + + const { container } = await renderAction({ user: { superUser: true } }) + + await waitFor(() => expect(consoleLog).toHaveBeenCalled()) + expect(getSelectItems(container, '过滤规则组')).toEqual([]) + }) +}) diff --git a/src/components/workflow/__tests__/ScanFileAction.spec.ts b/src/components/workflow/__tests__/ScanFileAction.spec.ts new file mode 100644 index 00000000..7fba6d56 --- /dev/null +++ b/src/components/workflow/__tests__/ScanFileAction.spec.ts @@ -0,0 +1,52 @@ +import ScanFileAction from '@/components/workflow/ScanFileAction.vue' +import { waitFor } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getSelectItems, workflowActionStubs } from './workflowActionTestUtils' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ + get: (...args: unknown[]) => mocks.apiGet(...args), + }), +})) + +async function renderAction() { + return renderWithProviders(ScanFileAction, { + props: { + id: 'scan-file', + data: { storage: '', directory: '' }, + }, + global: { stubs: workflowActionStubs }, + }) +} + +describe('ScanFileAction', () => { + beforeEach(() => { + mocks.apiGet.mockReset() + }) + + it('maps unwrapped storage names and types to options', async () => { + mocks.apiGet.mockResolvedValue({ + success: true, + message: '', + data: { + value: [ + { name: '本地存储', type: 'local' }, + { name: '阿里云盘', type: 'alipan' }, + ], + }, + }) + + const { container } = await renderAction() + + await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('system/setting/public/Storages')) + expect(getSelectItems(container, '存储')).toEqual([ + { title: '本地存储', value: 'local' }, + { title: '阿里云盘', value: 'alipan' }, + ]) + }) +}) diff --git a/src/components/workflow/__tests__/SendMessageAction.spec.ts b/src/components/workflow/__tests__/SendMessageAction.spec.ts new file mode 100644 index 00000000..ca482de5 --- /dev/null +++ b/src/components/workflow/__tests__/SendMessageAction.spec.ts @@ -0,0 +1,70 @@ +import SendMessageAction from '@/components/workflow/SendMessageAction.vue' +import { waitFor } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getSelectItems, workflowActionStubs } from './workflowActionTestUtils' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ + get: (...args: unknown[]) => mocks.apiGet(...args), + }), +})) + +async function renderAction(initialState: Record> = {}) { + return renderWithProviders(SendMessageAction, { + props: { + id: 'send-message', + data: { client: [], userid: '' }, + }, + initialState, + global: { stubs: workflowActionStubs }, + }) +} + +describe('SendMessageAction', () => { + beforeEach(() => { + mocks.apiGet.mockReset() + }) + + it('does not request admin notification channels for regular users', async () => { + const { container } = await renderAction() + + expect(mocks.apiGet).not.toHaveBeenCalled() + expect(getSelectItems(container, '渠道')).toEqual([]) + }) + + it('maps unwrapped admin notification channels to name options', async () => { + mocks.apiGet.mockResolvedValue({ + success: true, + message: '', + data: { + value: [ + { name: 'Telegram', type: 'telegram', enabled: true }, + { name: '企业微信', type: 'wechat', enabled: false }, + ], + }, + }) + + const { container } = await renderAction({ user: { superUser: true } }) + + await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('system/setting/Notifications')) + expect(getSelectItems(container, '渠道')).toEqual([ + { title: 'Telegram', value: 'Telegram' }, + { title: '企业微信', value: '企业微信' }, + ]) + }) + + it('keeps notification options empty when loading fails', async () => { + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) + mocks.apiGet.mockRejectedValue(new Error('notifications unavailable')) + + const { container } = await renderAction({ user: { superUser: true } }) + + await waitFor(() => expect(consoleLog).toHaveBeenCalled()) + expect(getSelectItems(container, '渠道')).toEqual([]) + }) +}) diff --git a/src/components/workflow/__tests__/workflowActionTestUtils.ts b/src/components/workflow/__tests__/workflowActionTestUtils.ts new file mode 100644 index 00000000..2091eebf --- /dev/null +++ b/src/components/workflow/__tests__/workflowActionTestUtils.ts @@ -0,0 +1,69 @@ +import { defineComponent, h } from 'vue' + +/** 将布局和连接器替换为可预测的容器,测试只观察动作配置数据。 */ +export const BoxStub = defineComponent({ + name: 'WorkflowActionBoxStub', + inheritAttrs: false, + setup(_, { attrs, slots }) { + return () => h('div', attrs, slots.default?.()) + }, +}) + +/** 暴露 VSelect 的 items,避免测试依赖 Vuetify 菜单和 Teleport 实现。 */ +export const SelectStub = defineComponent({ + name: 'WorkflowActionSelectStub', + inheritAttrs: false, + props: { + label: { + type: String, + default: '', + }, + items: { + type: Array, + default: () => [], + }, + }, + setup(props) { + return () => + h('div', { + 'data-select-label': props.label, + 'data-select-items': JSON.stringify(props.items), + }) + }, +}) + +/** 输入控件仅保留可渲染形状;v-model 和 Vuetify 内部行为不属于本组契约。 */ +export const InputStub = defineComponent({ + name: 'WorkflowActionInputStub', + inheritAttrs: false, + setup(_, { attrs }) { + return () => h('input', { 'aria-label': typeof attrs.label === 'string' ? attrs.label : undefined }) + }, +}) + +export const workflowActionStubs = { + Handle: BoxStub, + VAvatar: BoxStub, + VCard: BoxStub, + VCardItem: BoxStub, + VCardSubtitle: BoxStub, + VCardText: BoxStub, + VCardTitle: BoxStub, + VCol: BoxStub, + VDivider: BoxStub, + VIcon: BoxStub, + VPathField: InputStub, + VRow: BoxStub, + VSelect: SelectStub, + VSwitch: InputStub, + VTextField: InputStub, +} + +/** 按业务字段读取稳定 stub 暴露的选项。 */ +export function getSelectItems(container: ParentNode, label: string): unknown[] { + const element = [...container.querySelectorAll('[data-select-items]')].find( + item => item.dataset.selectLabel === label, + ) + if (!element) throw new Error(`Select stub not found: ${label}`) + return JSON.parse(element.dataset.selectItems ?? '[]') as unknown[] +} diff --git a/vite.config.ts b/vite.config.ts index 5add9aa6..7bc13278 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -366,6 +366,10 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/components/dialog/WorkflowActionsDialog.vue', 'src/components/workflow/InvokePluginAction.vue', 'src/components/workflow/WorkflowSidebar.vue', + 'src/components/workflow/FetchTorrentsAction.vue', + 'src/components/workflow/FilterTorrentsAction.vue', + 'src/components/workflow/ScanFileAction.vue', + 'src/components/workflow/SendMessageAction.vue', 'src/components/dialog/OTPAuthDialog.vue', 'src/components/dialog/PasskeyDialog.vue', 'src/components/dialog/SiteCookieUpdateDialog.vue', @@ -608,6 +612,30 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 85, statements: 85, }, + 'src/components/workflow/FetchTorrentsAction.vue': { + branches: 90, + functions: 30, + lines: 90, + statements: 90, + }, + 'src/components/workflow/FilterTorrentsAction.vue': { + branches: 85, + functions: 10, + lines: 90, + statements: 90, + }, + 'src/components/workflow/ScanFileAction.vue': { + branches: 80, + functions: 30, + lines: 90, + statements: 90, + }, + 'src/components/workflow/SendMessageAction.vue': { + branches: 85, + functions: 30, + lines: 90, + statements: 90, + }, 'src/@core/utils/workflow.ts': { branches: 85, functions: 90,