From 9654bd9852869c910a4bc856c7981294dde2da06 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:08:58 +0800 Subject: [PATCH] test(settings): cover notification configuration (#674) --- eslint-suppressions.json | 5 - .../cards/__tests__/MediaCard.spec.ts | 16 + .../AddSubtitleDownloadDialog.spec.ts | 21 ++ .../setting/AccountSettingNotification.vue | 9 +- .../AccountSettingNotification.spec.ts | 329 ++++++++++++++++++ vite.config.ts | 7 + 6 files changed, 379 insertions(+), 8 deletions(-) create mode 100644 src/views/setting/__tests__/AccountSettingNotification.spec.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a954ebde..e103b7b3 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -809,11 +809,6 @@ "count": 1 } }, - "src/views/setting/AccountSettingNotification.vue": { - "sonarjs/no-all-duplicated-branches": { - "count": 1 - } - }, "src/views/setting/AccountSettingRule.vue": { "@typescript-eslint/no-explicit-any": { "count": 8 diff --git a/src/components/cards/__tests__/MediaCard.spec.ts b/src/components/cards/__tests__/MediaCard.spec.ts index 8884a5e4..6ac700cb 100644 --- a/src/components/cards/__tests__/MediaCard.spec.ts +++ b/src/components/cards/__tests__/MediaCard.spec.ts @@ -283,6 +283,22 @@ describe('MediaCard', () => { }) }) + it('keeps the local-exists marker hidden when the exists request reports a business failure', async () => { + const media = createMediaInfo({ title: '存在查询失败电影', tmdb_id: 9103 }) + const existsRequest = vi.fn<(url: URL) => void>() + server.use( + querySubscribeByMediaHandler('9103', {}), + mediaExistsHandler({ data: {}, message: '查询失败', success: false }, 200, existsRequest), + ) + + const { container } = await renderCard(media) + getStatusObservers()[0]?.trigger() + + await waitFor(() => expect(existsRequest).toHaveBeenCalledOnce()) + await waitFor(() => expect(console.error).toHaveBeenCalled()) + expect(container.querySelector('.bg-green-500')).toBeNull() + }) + it.each([ ['TMDB', createMediaInfo({ season: 3, tmdb_id: 9201, type: '电视剧' }), '9201', '3'], [ diff --git a/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts b/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts index c712faae..3bc8b924 100644 --- a/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts +++ b/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts @@ -292,6 +292,27 @@ describe('AddSubtitleDownloadDialog submissions', () => { }) }) + it('reveals advanced options and submits the selected media source identity', async () => { + const submitted = vi.fn() + server.use(subtitleDownloadHandler({ data: null, success: true }, 200, submitted)) + const user = userEvent.setup() + + await renderDialog({ mediaId: '84', mediaSource: 'themoviedb' }) + + expect(screen.getByLabelText('识别数据源')).not.toBeVisible() + await user.click(screen.getByRole('button', { name: '显示高级选项' })) + await user.selectOptions(screen.getByLabelText('识别数据源'), 'imdb') + await user.clear(screen.getByLabelText('IMDb ID')) + await user.type(screen.getByLabelText('IMDb ID'), 'tt0111161') + await user.click(screen.getByRole('button', { name: '下载字幕' })) + + await waitFor(() => expect(submitted).toHaveBeenCalledOnce()) + expect(submitted.mock.calls[0][0]).toMatchObject({ + media_id: 'tt0111161', + media_source: 'imdb', + }) + }) + it('preserves the signed enclosure in download/subtitle and prevents duplicate submission', async () => { const deferred = createDeferred() const submitted = vi.fn() diff --git a/src/views/setting/AccountSettingNotification.vue b/src/views/setting/AccountSettingNotification.vue index 85136f11..e2995eee 100644 --- a/src/views/setting/AccountSettingNotification.vue +++ b/src/views/setting/AccountSettingNotification.vue @@ -206,14 +206,15 @@ function trackWechatClawBotRename(oldName: string, newName: string) { return } const renameMap = { ...wechatClawBotRenameMap.value } + let chainedRename = false + // 连续改名只保留原始缓存名到当前渠道名,避免为不存在的中间名发起迁移。 for (const [source, target] of Object.entries(renameMap)) { if (target === oldName) { renameMap[source] = newName + chainedRename = true } } - if (renameMap[oldName]) { - renameMap[oldName] = newName - } else { + if (!chainedRename) { renameMap[oldName] = newName } wechatClawBotRenameMap.value = Object.fromEntries( @@ -325,6 +326,7 @@ async function saveNotificationTime() { $toast.success(t('setting.notification.timeSaveSuccess')) } catch (error) { console.log(error) + $toast.error(t('setting.notification.timeSaveFailed')) } } @@ -367,6 +369,7 @@ async function saveNotificationSwitchs() { $toast.success(t('setting.notification.switchSaveSuccess')) } catch (error) { console.log(error) + $toast.error(t('setting.notification.switchSaveFailed')) } } diff --git a/src/views/setting/__tests__/AccountSettingNotification.spec.ts b/src/views/setting/__tests__/AccountSettingNotification.spec.ts new file mode 100644 index 00000000..0f41a683 --- /dev/null +++ b/src/views/setting/__tests__/AccountSettingNotification.spec.ts @@ -0,0 +1,329 @@ +import vuetify from '@/plugins/vuetify' +import AccountSettingNotification from '@/views/setting/AccountSettingNotification.vue' +import { fireEvent, screen, waitFor, within } 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(() => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + openSharedDialog: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), + useSilentSettingRefresh: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ get: mocks.apiGet, post: mocks.apiPost }), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: mocks.openSharedDialog, +})) + +vi.mock('@/composables/useSilentSettingRefresh', () => ({ + useSilentSettingRefresh: mocks.useSilentSettingRefresh, +})) + +vi.mock('@/components/cards/NotificationChannelCard.vue', async () => { + const { defineComponent } = await import('vue') + return { + default: defineComponent({ + name: 'NotificationChannelCardStub', + props: { notification: { type: Object, required: true } }, + emits: ['change', 'close'], + template: ` +
+ {{ notification.name }} / {{ notification.type }} + + +
+ `, + }), + } +}) + +vi.mock('vuedraggable', async () => { + const { defineComponent, h } = await import('vue') + return { + default: defineComponent({ + name: 'DraggableStub', + props: { modelValue: { type: Array, default: () => [] } }, + emits: ['update:modelValue'], + setup(props, { emit, slots }) { + const reverse = () => emit('update:modelValue', [...props.modelValue].reverse()) + return () => { + const items = props.modelValue as Array<{ name?: string }> + return h('div', [ + h('button', { 'aria-label': 'reverse-channels', onClick: reverse }, 'reverse'), + ...items.map(element => slots.item?.({ element })), + ]) + } + }, + }), + } +}) + +const notificationsFixture = [ + { name: 'Alpha', type: 'wechatclawbot', enabled: true, config: { token: 'fixture-token' } }, + { name: '通知3', type: 'telegram', enabled: false, config: {} }, +] + +const templateFixture = { + organizeSuccess: '{"title":"organized"}', + downloadAdded: '{"title":"downloaded"}', + subscribeAdded: '{}', + subscribeComplete: '{}', +} + +function mockLoadedSettings() { + mocks.apiGet.mockImplementation((endpoint: string) => { + if (endpoint === 'system/setting/Notifications') { + return { success: true, data: { value: structuredClone(notificationsFixture) } } + } + if (endpoint === 'system/setting/NotificationSwitchs') { + return { success: true, data: { value: [{ type: '资源下载', action: 'user' }] } } + } + if (endpoint === 'system/setting/NotificationSendTime') { + return { success: true, data: { value: { start: '08:30', end: '22:00' } } } + } + if (endpoint === 'system/setting/NotificationTemplates') { + return { success: true, data: { value: structuredClone(templateFixture) } } + } + throw new Error(`Unexpected GET ${endpoint}`) + }) + mocks.apiPost.mockResolvedValue({ success: true }) +} + +function createDialogController() { + return { close: vi.fn(), id: 1, updateProps: vi.fn() } +} + +async function renderNotificationSettings() { + return renderWithProviders(AccountSettingNotification) +} + +function getCard(title: string) { + const card = screen.getByText(title).closest('.v-card') + expect(card).not.toBeNull() + return within(card as HTMLElement) +} + +function getDialogEvents() { + return mocks.openSharedDialog.mock.calls.at(-1)?.[2] as { + close: () => void + save: (value: string) => Promise + 'update:content': (value: string) => void + 'update:modelValue': (value: boolean) => void + } +} + +describe('AccountSettingNotification', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + mocks.apiGet.mockReset() + mocks.apiPost.mockReset() + mocks.openSharedDialog.mockReset() + mocks.toastError.mockReset() + mocks.toastSuccess.mockReset() + mocks.useSilentSettingRefresh.mockReset() + mocks.openSharedDialog.mockImplementation(() => createDialogController()) + mockLoadedSettings() + }) + + it('loads owned settings, adds missing switch defaults, and follows active refresh state', async () => { + const { rerender } = await renderNotificationSettings() + + expect(await screen.findByText('Alpha / wechatclawbot')).toBeInTheDocument() + expect(screen.getByText('通知3 / telegram')).toBeInTheDocument() + expect(screen.getByLabelText('开始时间')).toHaveValue('08:30') + expect(screen.getByLabelText('结束时间')).toHaveValue('22:00') + expect(getCard('通知发送范围').getAllByRole('radiogroup')).toHaveLength(9) + + const refreshOptions = mocks.useSilentSettingRefresh.mock.calls[0]?.[1] + expect(refreshOptions.active.value).toBe(true) + await rerender({ active: false }) + expect(refreshOptions.active.value).toBe(false) + }) + + it('creates a unique automatic channel name, removes channels, and saves the current order', async () => { + const user = userEvent.setup() + await renderNotificationSettings() + await screen.findByText('Alpha / wechatclawbot') + const channelCard = getCard('通知渠道') + + const buttons = channelCard.getAllByRole('button') + await user.click(buttons.at(-1)!) + await user.click(await screen.findByText('企业微信', { selector: '.v-list-item-title' })) + expect(screen.getByText('通知4 / wechat')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'remove-通知3' })) + await user.click(screen.getByRole('button', { name: 'reverse-channels' })) + await user.click(channelCard.getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/Notifications', [ + expect.objectContaining({ name: '通知4', type: 'wechat' }), + expect.objectContaining({ name: 'Alpha', type: 'wechatclawbot' }), + ]) + }) + expect(mocks.toastSuccess).toHaveBeenCalledWith('通知设置保存成功') + }) + + it('compresses chained ClawBot renames and migrates the original source before saving channels', async () => { + const user = userEvent.setup() + await renderNotificationSettings() + await screen.findByText('Alpha / wechatclawbot') + + await fireEvent.update(screen.getByLabelText('name-Alpha'), 'Beta') + await fireEvent.update(await screen.findByLabelText('name-Beta'), 'Gamma') + await user.click(getCard('通知渠道').getByRole('button', { name: '保存' })) + + await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2)) + expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'notification/wechatclawbot/migrate', null, { + params: { old_source: 'Alpha', new_source: 'Gamma' }, + }) + expect(mocks.apiPost).toHaveBeenNthCalledWith( + 2, + 'system/setting/Notifications', + expect.arrayContaining([expect.objectContaining({ name: 'Gamma', type: 'wechatclawbot' })]), + ) + }) + + it('keeps a failed ClawBot migration pending and retries it before saving channels', async () => { + const user = userEvent.setup() + await renderNotificationSettings() + await screen.findByText('Alpha / wechatclawbot') + await fireEvent.update(screen.getByLabelText('name-Alpha'), 'Beta') + const save = getCard('通知渠道').getByRole('button', { name: '保存' }) + + mocks.apiPost.mockResolvedValueOnce({ success: false, message: 'migration failed' }) + await user.click(save) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('通知设置保存失败!')) + expect(mocks.apiPost).toHaveBeenCalledTimes(1) + + mocks.apiPost.mockClear() + mocks.apiPost.mockResolvedValue({ success: true }) + await user.click(save) + await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2)) + expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'notification/wechatclawbot/migrate', null, { + params: { old_source: 'Alpha', new_source: 'Beta' }, + }) + expect(mocks.apiPost).toHaveBeenNthCalledWith( + 2, + 'system/setting/Notifications', + expect.arrayContaining([expect.objectContaining({ name: 'Beta' })]), + ) + }) + + it('loads, edits, and saves a template while pausing refresh and following the active theme', async () => { + const previousTheme = vuetify.theme.global.name.value + const controller = createDialogController() + mocks.openSharedDialog.mockReturnValue(controller) + + try { + const user = userEvent.setup() + await renderNotificationSettings() + await screen.findByText('Alpha / wechatclawbot') + const templateButton = screen.getByRole('button', { name: /资源入库/ }) + await user.click(templateButton) + + await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce()) + expect(mocks.openSharedDialog.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ content: templateFixture.organizeSuccess, editorTheme: 'github_light_default' }), + ) + expect(mocks.useSilentSettingRefresh.mock.calls[0]?.[1].active.value).toBe(false) + + getDialogEvents()['update:content']('{"title":"updated"}') + vuetify.theme.global.name.value = 'dark' + await waitFor(() => expect(controller.updateProps).toHaveBeenCalledWith({ editorTheme: 'github_dark' })) + await getDialogEvents().save('{"title":"updated"}') + + expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/NotificationTemplates', { + ...templateFixture, + organizeSuccess: '{"title":"updated"}', + }) + expect(mocks.toastSuccess).toHaveBeenCalledWith('模板保存成功') + expect(controller.close).toHaveBeenCalledOnce() + } finally { + vuetify.theme.global.name.value = previousTheme + } + }) + + it('keeps the template editor open when saving fails and reports load failures', async () => { + const user = userEvent.setup() + const controller = createDialogController() + mocks.openSharedDialog.mockReturnValue(controller) + await renderNotificationSettings() + await screen.findByText('Alpha / wechatclawbot') + const templateButton = screen.getByRole('button', { name: /资源下载/ }) + await user.click(templateButton) + await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce()) + + mocks.apiPost.mockRejectedValueOnce(new Error('offline')) + await getDialogEvents().save('{"title":"failed"}') + expect(mocks.toastError).toHaveBeenCalledWith('模板保存失败!') + expect(controller.close).not.toHaveBeenCalled() + + mocks.apiGet.mockImplementation((endpoint: string) => { + if (endpoint === 'system/setting/NotificationTemplates') throw new Error('offline') + return mockLoadedSettings() + }) + mocks.openSharedDialog.mockClear() + await user.click(screen.getByRole('button', { name: /添加订阅/ })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('模板加载失败!')) + expect(mocks.openSharedDialog).not.toHaveBeenCalled() + }) + + it('saves edited notification time and merged message scope payloads', async () => { + const user = userEvent.setup() + await renderNotificationSettings() + await screen.findByText('Alpha / wechatclawbot') + + await fireEvent.update(screen.getByLabelText('开始时间'), '09:15') + await fireEvent.update(screen.getByLabelText('结束时间'), '21:45') + await user.click(getCard('通知发送时间').getByRole('button', { name: '保存' })) + expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/NotificationSendTime', { + start: '09:15', + end: '21:45', + }) + + const scopeCard = getCard('通知发送范围') + await user.click(scopeCard.getAllByRole('radio', { name: '仅管理员' })[0]) + await user.click(scopeCard.getByRole('button', { name: '保存' })) + expect(mocks.apiPost).toHaveBeenCalledWith( + 'system/setting/NotificationSwitchs', + expect.arrayContaining([ + { type: '资源下载', action: 'admin' }, + { type: '智能体', action: 'admin' }, + { type: '其它', action: 'admin' }, + ]), + ) + expect(mocks.toastSuccess).toHaveBeenCalledWith('通知发送时间保存成功') + expect(mocks.toastSuccess).toHaveBeenCalledWith('消息类型开关保存成功') + }) + + it('reports HTTP failures when saving notification time or message scopes', async () => { + const user = userEvent.setup() + await renderNotificationSettings() + await screen.findByText('Alpha / wechatclawbot') + + mocks.apiPost.mockRejectedValueOnce(new Error('offline')) + await user.click(getCard('通知发送时间').getByRole('button', { name: '保存' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('通知发送时间保存失败!')) + + mocks.apiPost.mockRejectedValueOnce(new Error('offline')) + await user.click(getCard('通知发送范围').getByRole('button', { name: '保存' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('消息类型开关保存失败!')) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index fc86daca..dd22df7c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -389,6 +389,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/utils/searchStream.ts', 'src/views/site/SiteCardListView.vue', 'src/views/setting/AccountSettingDirectory.vue', + 'src/views/setting/AccountSettingNotification.vue', 'src/views/setting/AccountSettingRule.vue', 'src/views/setting/AccountSettingSearch.vue', 'src/views/setting/AccountSettingSite.vue', @@ -455,6 +456,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 80, statements: 80, }, + 'src/views/setting/AccountSettingNotification.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, 'src/views/setting/AccountSettingRule.vue': { branches: 75, functions: 80,