From 7c02bf7c5c116c0c7f6e2947a3b0b0b31df7f199 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:26:00 +0800 Subject: [PATCH] fix(workflow): align share response contracts (#708) --- .../cards/__tests__/WorkflowShareCard.spec.ts | 94 +++++++ src/components/dialog/ForkWorkflowDialog.vue | 8 +- .../__tests__/ForkWorkflowDialog.spec.ts | 265 +++++++++++++++++- .../__tests__/WorkflowShareDialog.spec.ts | 169 +++++++++++ vite.config.ts | 21 ++ 5 files changed, 541 insertions(+), 16 deletions(-) create mode 100644 src/components/cards/__tests__/WorkflowShareCard.spec.ts create mode 100644 src/components/dialog/__tests__/WorkflowShareDialog.spec.ts diff --git a/src/components/cards/__tests__/WorkflowShareCard.spec.ts b/src/components/cards/__tests__/WorkflowShareCard.spec.ts new file mode 100644 index 00000000..baff2422 --- /dev/null +++ b/src/components/cards/__tests__/WorkflowShareCard.spec.ts @@ -0,0 +1,94 @@ +import type { WorkflowShare } from '@/api/types' +import WorkflowShareCard from '@/components/cards/WorkflowShareCard.vue' +import { screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '@tests/support/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + openSharedDialog: vi.fn(), +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + +function createWorkflowShare(overrides: Partial = {}): WorkflowShare { + return { + date: '2026-08-23 12:00:00', + id: '91', + share_comment: '用于验证工作流分享卡片', + share_title: '分享工作流', + share_user: '测试用户', + ...overrides, + } +} + +async function renderCard(workflow = createWorkflowShare()) { + const events = { + delete: vi.fn(), + update: vi.fn(), + } + const result = await renderWithProviders(WorkflowShareCard, { + props: { + eventTypes: [{ title: '下载完成', value: 'download_complete' }], + onDelete: events.delete, + onUpdate: events.update, + workflow, + }, + }) + + return { ...result, events, workflow } +} + +describe('WorkflowShareCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opens the fork dialog with the current workflow and closes on terminal events', async () => { + const user = userEvent.setup() + const { workflow } = await renderCard() + + await user.click(screen.getByText('分享工作流')) + + expect(mocks.openSharedDialog).toHaveBeenCalledOnce() + expect(mocks.openSharedDialog.mock.calls[0][1]).toEqual({ + eventTypes: [{ title: '下载完成', value: 'download_complete' }], + workflow, + }) + expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({ closeOn: ['close', 'fork', 'delete'] }) + }) + + it('renders usage and date metadata with an ID-derived gradient', async () => { + const { container } = await renderCard(createWorkflowShare({ count: 1234 })) + + expect(screen.getByText('1,234')).toBeInTheDocument() + expect(container.querySelector('.absolute.right-0.bottom-0')).not.toHaveTextContent('') + expect(container.querySelector('.workflow-share-card')?.getAttribute('style')).toContain('linear-gradient') + }) + + it('uses a fallback gradient and omits optional metadata when identity and usage are absent', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.25) + const { container } = await renderCard(createWorkflowShare({ count: undefined, date: undefined, id: undefined })) + + expect(screen.queryByText('1,234')).not.toBeInTheDocument() + expect(container.querySelector('.workflow-share-card')?.getAttribute('style')).toContain('linear-gradient') + }) + + it('maps payload-free fork and delete completion to parent refresh events', async () => { + const user = userEvent.setup() + const { events } = await renderCard() + await user.click(screen.getByText('分享工作流')) + const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as { + delete: () => void + fork: () => void + } + + dialogEvents.fork() + dialogEvents.delete() + + expect(events.update).toHaveBeenCalledOnce() + expect(events.delete).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/dialog/ForkWorkflowDialog.vue b/src/components/dialog/ForkWorkflowDialog.vue index 821a5198..1a18d5aa 100644 --- a/src/components/dialog/ForkWorkflowDialog.vue +++ b/src/components/dialog/ForkWorkflowDialog.vue @@ -78,10 +78,10 @@ async function doFork() { try { processing.value = true // 请求API - const result = await api.post<{ id: string }>('workflow/fork', props.workflow, { feedback: 'silent' }) + await api.post('workflow/fork', props.workflow, { feedback: 'silent' }) $toast.success(t('workflow.addSuccess', { name: props.workflow?.share_title })) // 完成 - emit('fork', result.id) + emit('fork') } catch (error) { console.error(error) $toast.error( @@ -103,7 +103,7 @@ async function doDelete() { try { deleting.value = true // 请求API - const result = await api.delete<{ id: string }>(`workflow/share/${props.workflow?.id}`, { + await api.delete(`workflow/share/${props.workflow?.id}`, { params: { share_uid: globalSettings.USER_UNIQUE_ID, }, @@ -111,7 +111,7 @@ async function doDelete() { }) $toast.success(t('workflow.cancelSuccess')) // 完成 - emit('delete', result.id) + emit('delete') } catch (error) { console.error(error) $toast.error(t('workflow.cancelFailed', { message: error instanceof Error ? error.message : '' })) diff --git a/src/components/dialog/__tests__/ForkWorkflowDialog.spec.ts b/src/components/dialog/__tests__/ForkWorkflowDialog.spec.ts index ed71fe1f..774f66de 100644 --- a/src/components/dialog/__tests__/ForkWorkflowDialog.spec.ts +++ b/src/components/dialog/__tests__/ForkWorkflowDialog.spec.ts @@ -1,19 +1,260 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' +import type { WorkflowShare } from '@/api/types' +import ForkWorkflowDialog from '@/components/dialog/ForkWorkflowDialog.vue' +import { screen, waitFor } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { HttpResponse, http } from 'msw' +import { apiFailureJson, apiJson } from '@tests/support/msw/response' +import { server } from '@tests/support/msw/server' +import { renderWithProviders } from '@tests/support/render' +import { defineComponent, h } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' -const dialogSource = readFileSync('src/components/dialog/ForkWorkflowDialog.vue', 'utf8') +const mocks = vi.hoisted(() => ({ + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) -describe('ForkWorkflowDialog preview contract', () => { - it('uses the static summary without loading an interactive VueFlow canvas', () => { - expect(dialogSource).toContain('WorkflowSummaryPreview') - expect(dialogSource).not.toContain('@vue-flow/core') - expect(dialogSource).not.toContain('import.meta.glob') - expect(dialogSource).not.toContain(' ({ + configureNProgress: vi.fn(), + doneNProgress: vi.fn(), + startNProgress: vi.fn(), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), +})) + +const DialogCloseButtonStub = defineComponent({ + name: 'VDialogCloseBtn', + emits: ['click'], + setup(_props, { emit }) { + return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭') + }, +}) + +const WorkflowSummaryPreviewStub = defineComponent({ + name: 'WorkflowSummaryPreview', + props: { + actions: Array, + flows: Array, + }, + setup(props) { + return () => h('div', { 'data-testid': 'workflow-preview' }, `${props.actions?.length}:${props.flows?.length}`) + }, +}) + +function createWorkflowShare(overrides: Partial = {}): WorkflowShare { + return { + actions: [{ id: 'action-1' }], + flows: [{ id: 'flow-1' }], + id: '91', + share_comment: '用于验证工作流分享契约', + share_title: '分享工作流', + share_uid: 'owner-id', + share_user: '测试用户', + trigger_type: 'manual', + ...overrides, + } +} + +function createDeferred() { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} + +async function renderDialog(workflow = createWorkflowShare(), settings: Record = {}) { + const events = { + close: vi.fn(), + delete: vi.fn(), + fork: vi.fn(), + } + const result = await renderWithProviders(ForkWorkflowDialog, { + global: { + components: { + VDialogCloseBtn: DialogCloseButtonStub, + }, + stubs: { + WorkflowSummaryPreview: WorkflowSummaryPreviewStub, + }, + }, + initialState: { + globalSettings: { + data: { + USER_UNIQUE_ID: 'owner-id', + WORKFLOW_SHARE_MANAGE: false, + ...settings, + }, + }, + }, + props: { + eventTypes: [{ title: '下载完成', value: 'download_complete' }], + modelValue: true, + onClose: events.close, + onDelete: events.delete, + onFork: events.fork, + workflow, + }, }) - it('reserves the close-button safe area on small screens', () => { - expect(dialogSource).toMatch( - /@media screen and \(width <= 600px\)[\s\S]*?\.workflow-share-layout\s*\{[\s\S]*?padding-block-start:\s*2rem/, + return { ...result, events, workflow } +} + +describe('ForkWorkflowDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('renders parsed workflow data through the static summary preview', async () => { + await renderDialog( + createWorkflowShare({ + actions: JSON.stringify([{ id: 'action-1' }, { id: 'action-2' }]) as unknown as WorkflowShare['actions'], + flows: JSON.stringify([{ id: 'flow-1' }]) as unknown as WorkflowShare['flows'], + }), ) + + expect(screen.getByTestId('workflow-preview')).toHaveTextContent('2:1') + expect(document.querySelector('.workflow-share-layout')).toBeInTheDocument() + }) + + it.each([ + ['timer', { timer: '0 8 * * *', trigger_type: 'timer' }, '0 8 * * *'], + ['legacy timer', { timer: '0 9 * * *', trigger_type: undefined }, '0 9 * * *'], + ['known event', { event_type: 'download_complete', trigger_type: 'event' }, '下载完成'], + ['unknown event', { event_type: 'custom_event', trigger_type: 'event' }, 'custom_event'], + ['manual', { trigger_type: 'manual' }, '手动触发'], + ])('renders the %s trigger contract', async (_case, overrides, expected) => { + await renderDialog(createWorkflowShare(overrides)) + + expect(screen.getByText(expected)).toBeInTheDocument() + }) + + it('falls back to an empty static preview when serialized workflow data is malformed', async () => { + await renderDialog( + createWorkflowShare({ + actions: '{invalid' as unknown as WorkflowShare['actions'], + flows: 'not-json' as unknown as WorkflowShare['flows'], + }), + ) + + expect(screen.getByTestId('workflow-preview')).toHaveTextContent('0:0') + expect(console.error).toHaveBeenCalledWith('解析工作流数据失败:', expect.any(SyntaxError)) + }) + + it('keeps the fork action pending and completes a Response[None] success without reading data.id', async () => { + const deferred = createDeferred() + const requested = vi.fn(async (payload: unknown) => { + await deferred.promise + return payload + }) + server.use( + http.post('/api/v1/workflow/fork', async ({ request }) => { + await requested(await request.json()) + return apiJson(null) + }), + ) + const user = userEvent.setup() + const { events, workflow } = await renderDialog() + const button = screen.getByRole('button', { name: '复用工作流' }) + + await user.click(button) + await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + expect(button).toBeDisabled() + expect(events.fork).not.toHaveBeenCalled() + + deferred.resolve() + await waitFor(() => expect(events.fork).toHaveBeenCalledOnce()) + expect(events.fork).toHaveBeenCalledWith() + expect(requested).toHaveBeenCalledWith(workflow) + expect(mocks.toastSuccess).toHaveBeenCalledWith('复用 分享工作流 成功!') + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('reports fork business and HTTP failures without emitting completion', async () => { + const user = userEvent.setup() + const { events, rerender } = await renderDialog(createWorkflowShare({ share_title: '拒绝复用' })) + + server.use(http.post('/api/v1/workflow/fork', () => apiFailureJson('工作流无效'))) + await user.click(screen.getByRole('button', { name: '复用工作流' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('复用 拒绝复用 失败:工作流无效')) + expect(events.fork).not.toHaveBeenCalled() + + mocks.toastError.mockClear() + server.use(http.post('/api/v1/workflow/fork', () => HttpResponse.json({}, { status: 500 }))) + await rerender({ workflow: createWorkflowShare({ share_title: '网络失败' }) }) + await user.click(screen.getByRole('button', { name: '复用工作流' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('复用 网络失败 失败'))) + expect(events.fork).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: '复用工作流' })).not.toBeDisabled() + }) + + it.each([ + ['the owner', 'owner-id', false, true], + ['a share manager', 'other-user', true, true], + ['another ordinary user', 'other-user', false, false], + ])('shows delete permission for %s', async (_case, shareUid, canManage, visible) => { + await renderDialog(createWorkflowShare({ share_uid: shareUid }), { WORKFLOW_SHARE_MANAGE: canManage }) + + if (visible) expect(screen.getByRole('button', { name: '取消分享' })).toBeInTheDocument() + else expect(screen.queryByRole('button', { name: '取消分享' })).not.toBeInTheDocument() + }) + + it('deletes the exact share and completes a Response[None] success without reading data.id', async () => { + const deferred = createDeferred() + const requested = vi.fn(async (url: URL) => { + await deferred.promise + return url + }) + server.use( + http.delete('/api/v1/workflow/share/:id', async ({ request }) => { + await requested(new URL(request.url)) + return apiJson(null) + }), + ) + const user = userEvent.setup() + const { events } = await renderDialog() + const button = screen.getByRole('button', { name: '取消分享' }) + + await user.click(button) + await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + expect(button).toBeDisabled() + expect(events.delete).not.toHaveBeenCalled() + + deferred.resolve() + await waitFor(() => expect(events.delete).toHaveBeenCalledOnce()) + expect(events.delete).toHaveBeenCalledWith() + expect(requested.mock.calls[0][0].pathname).toBe('/api/v1/workflow/share/91') + expect(requested.mock.calls[0][0].searchParams.get('share_uid')).toBe('owner-id') + expect(mocks.toastSuccess).toHaveBeenCalledWith('取消分享成功') + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('reports delete business and HTTP failures without emitting completion', async () => { + const user = userEvent.setup() + const { events } = await renderDialog() + + server.use(http.delete('/api/v1/workflow/share/:id', () => apiFailureJson('没有删除权限'))) + await user.click(screen.getByRole('button', { name: '取消分享' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('取消分享失败:没有删除权限')) + expect(events.delete).not.toHaveBeenCalled() + + mocks.toastError.mockClear() + server.use(http.delete('/api/v1/workflow/share/:id', () => HttpResponse.json({}, { status: 500 }))) + await user.click(screen.getByRole('button', { name: '取消分享' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('取消分享失败'))) + expect(events.delete).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: '取消分享' })).not.toBeDisabled() + }) + + it('emits close from the dialog close control', async () => { + const user = userEvent.setup() + const { events } = await renderDialog() + + await user.click(screen.getByRole('button', { name: '关闭' })) + + expect(events.close).toHaveBeenCalledOnce() }) }) diff --git a/src/components/dialog/__tests__/WorkflowShareDialog.spec.ts b/src/components/dialog/__tests__/WorkflowShareDialog.spec.ts new file mode 100644 index 00000000..576a702c --- /dev/null +++ b/src/components/dialog/__tests__/WorkflowShareDialog.spec.ts @@ -0,0 +1,169 @@ +import type { Workflow } from '@/api/types' +import WorkflowShareDialog from '@/components/dialog/WorkflowShareDialog.vue' +import { screen, waitFor } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { HttpResponse, http } from 'msw' +import { apiFailureJson, apiJson } from '@tests/support/msw/response' +import { server } from '@tests/support/msw/server' +import { renderWithProviders } from '@tests/support/render' +import { defineComponent, h } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), +})) + +const DialogCloseButtonStub = defineComponent({ + name: 'VDialogCloseBtn', + emits: ['click'], + setup(_props, { emit }) { + return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭') + }, +}) + +function createDeferred() { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} + +async function renderDialog(workflow: Workflow | null = { id: '81', name: '待分享工作流' }) { + const close = vi.fn() + const result = await renderWithProviders(WorkflowShareDialog, { + global: { + components: { + VDialogCloseBtn: DialogCloseButtonStub, + }, + }, + props: { + modelValue: true, + onClose: close, + workflow: workflow ?? undefined, + }, + }) + + return { ...result, close, workflow } +} + +async function fillRequiredFields(comment = '覆盖工作流分享契约', shareUser = '测试分享人') { + const user = userEvent.setup() + await user.type(screen.getByLabelText('说明'), comment) + await user.type(screen.getByLabelText('分享用户'), shareUser) + return user +} + +describe('WorkflowShareDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it.each([ + ['description', '', '测试分享人'], + ['sharing user', '覆盖工作流分享契约', ''], + ])('blocks submission when the %s is missing', async (_case, comment, shareUser) => { + const requested = vi.fn() + server.use( + http.post('/api/v1/workflow/share', () => { + requested() + return apiJson(null) + }), + ) + await renderDialog() + const user = userEvent.setup() + + if (comment) await user.type(screen.getByLabelText('说明'), comment) + if (shareUser) await user.type(screen.getByLabelText('分享用户'), shareUser) + await user.click(screen.getByRole('button', { name: '确认分享' })) + + expect(requested).not.toHaveBeenCalled() + expect(mocks.toastSuccess).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('submits the exact payload once, stays pending, and closes after success', async () => { + const deferred = createDeferred() + const payloads: unknown[] = [] + server.use( + http.post('/api/v1/workflow/share', async ({ request }) => { + payloads.push(await request.json()) + await deferred.promise + return apiJson(null) + }), + ) + const { close, workflow } = await renderDialog() + const expectedWorkflow = workflow! + const user = await fillRequiredFields('只提交分享字段', '分享者甲') + const button = screen.getByRole('button', { name: '确认分享' }) + + await user.click(button) + await waitFor(() => expect(payloads).toHaveLength(1)) + expect(payloads[0]).toEqual({ + id: expectedWorkflow.id, + share_comment: '只提交分享字段', + share_title: expectedWorkflow.name, + share_user: '分享者甲', + }) + expect(button).toBeDisabled() + expect(close).not.toHaveBeenCalled() + + deferred.resolve() + await waitFor(() => expect(close).toHaveBeenCalledOnce()) + expect(mocks.toastSuccess).toHaveBeenCalledWith('待分享工作流 分享成功!') + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('keeps the dialog retryable after business and HTTP failures', async () => { + const { close } = await renderDialog() + const user = await fillRequiredFields() + const button = screen.getByRole('button', { name: '确认分享' }) + + server.use(http.post('/api/v1/workflow/share', () => apiFailureJson('远端拒绝'))) + await user.click(button) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('待分享工作流 分享失败:远端拒绝!')) + expect(close).not.toHaveBeenCalled() + expect(button).not.toBeDisabled() + + mocks.toastError.mockClear() + server.use(http.post('/api/v1/workflow/share', () => HttpResponse.json({}, { status: 500 }))) + await user.click(button) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('待分享工作流 分享失败'))) + expect(close).not.toHaveBeenCalled() + expect(button).not.toBeDisabled() + }) + + it('tracks a replaced workflow in the submitted share identity', async () => { + const payloads: unknown[] = [] + server.use( + http.post('/api/v1/workflow/share', async ({ request }) => { + payloads.push(await request.json()) + return apiJson(null) + }), + ) + const { rerender } = await renderDialog(null) + expect(screen.getByLabelText('标题')).toHaveValue('') + await rerender({ workflow: { id: '82', name: '替换后的工作流' } }) + const user = await fillRequiredFields() + + await user.click(screen.getByRole('button', { name: '确认分享' })) + + await waitFor(() => expect(payloads).toHaveLength(1)) + expect(payloads[0]).toMatchObject({ id: '82', share_title: '替换后的工作流' }) + }) + + it('emits close from the dialog close control', async () => { + const user = userEvent.setup() + const { close } = await renderDialog() + + await user.click(screen.getByRole('button', { name: '关闭' })) + + expect(close).toHaveBeenCalledOnce() + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 8002d7f6..9a27c3eb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -404,6 +404,9 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/components/cards/PluginCard.vue', 'src/components/cards/PluginAppCard.vue', 'src/components/cards/WorkflowTaskCard.vue', + 'src/components/cards/WorkflowShareCard.vue', + 'src/components/dialog/ForkWorkflowDialog.vue', + 'src/components/dialog/WorkflowShareDialog.vue', 'src/components/dialog/PluginMarketDetailDialog.vue', 'src/components/dialog/PluginMarketSettingDialog.vue', 'src/components/dialog/PluginVersionHistoryDialog.vue', @@ -555,6 +558,24 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 85, statements: 85, }, + 'src/components/cards/WorkflowShareCard.vue': { + branches: 75, + functions: 85, + lines: 85, + statements: 85, + }, + 'src/components/dialog/ForkWorkflowDialog.vue': { + branches: 75, + functions: 85, + lines: 85, + statements: 85, + }, + 'src/components/dialog/WorkflowShareDialog.vue': { + branches: 75, + functions: 85, + lines: 85, + statements: 85, + }, 'src/components/dialog/UserAddEditDialog.vue': { branches: 75, functions: 80,