From d29033c23388b26625013ffd225743fff0364172 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:37:49 +0800 Subject: [PATCH] test(setting): cover search subscribe and site settings (#667) --- eslint-suppressions.json | 33 +-- src/views/setting/AccountSettingSearch.vue | 11 +- src/views/setting/AccountSettingSite.vue | 11 +- src/views/setting/AccountSettingSubscribe.vue | 10 +- .../__tests__/AccountSettingSearch.spec.ts | 181 +++++++++++++++ .../__tests__/AccountSettingSite.spec.ts | 210 ++++++++++++++++++ .../__tests__/AccountSettingSubscribe.spec.ts | 173 +++++++++++++++ vite.config.ts | 21 ++ 8 files changed, 611 insertions(+), 39 deletions(-) create mode 100644 src/views/setting/__tests__/AccountSettingSearch.spec.ts create mode 100644 src/views/setting/__tests__/AccountSettingSite.spec.ts create mode 100644 src/views/setting/__tests__/AccountSettingSubscribe.spec.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 0f88f246..d5b97c3e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -868,44 +868,17 @@ }, "src/views/setting/AccountSettingSearch.vue": { "@typescript-eslint/no-explicit-any": { - "count": 12 - }, - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, - "no-empty": { - "count": 1 - }, - "no-prototype-builtins": { - "count": 1 - }, - "sonarjs/no-ignored-exceptions": { - "count": 1 + "count": 11 } }, "src/views/setting/AccountSettingSite.vue": { "@typescript-eslint/no-explicit-any": { - "count": 6 - }, - "no-prototype-builtins": { - "count": 1 + "count": 5 } }, "src/views/setting/AccountSettingSubscribe.vue": { "@typescript-eslint/no-explicit-any": { - "count": 12 - }, - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, - "no-empty": { - "count": 1 - }, - "no-prototype-builtins": { - "count": 1 - }, - "sonarjs/no-ignored-exceptions": { - "count": 1 + "count": 11 } }, "src/views/setting/AccountSettingSystem.vue": { diff --git a/src/views/setting/AccountSettingSearch.vue b/src/views/setting/AccountSettingSearch.vue index 69216fbf..c47e2a16 100644 --- a/src/views/setting/AccountSettingSearch.vue +++ b/src/views/setting/AccountSettingSearch.vue @@ -119,13 +119,13 @@ async function querySelectedSites() { // 保存用户选中的站点 async function saveSelectedSites() { try { - // 用户名密码 const result: { [key: string]: any } = await api.post('system/setting/IndexerSites', selectedSites.value) if (result.success) $toast.success('搜索站点保存成功') else $toast.error('搜索站点保存失败!') } catch (error) { console.log(error) + $toast.error('搜索站点保存失败!') } } @@ -149,7 +149,9 @@ async function saveSystemSetting(value: { [key: string]: any }) { if (result.success) { return true } - } catch (error) {} + } catch { + return false + } return false } @@ -180,6 +182,7 @@ async function saveSearchSetting() { } } catch (error) { console.log(error) + $toast.error('搜索基础设置保存失败!') } } @@ -191,7 +194,9 @@ async function loadSystemSettings() { // 将API返回的值赋值给SystemSettings for (const sectionKey of Object.keys(SystemSettings.value) as Array) { Object.keys(SystemSettings.value[sectionKey]).forEach((key: string) => { - if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key] + if (Object.prototype.hasOwnProperty.call(result.data, key)) { + Reflect.set(SystemSettings.value[sectionKey], key, result.data[key]) + } }) } } diff --git a/src/views/setting/AccountSettingSite.vue b/src/views/setting/AccountSettingSite.vue index a552e4a4..3adbd3ce 100644 --- a/src/views/setting/AccountSettingSite.vue +++ b/src/views/setting/AccountSettingSite.vue @@ -87,11 +87,12 @@ async function resetSites() { const result: { [key: string]: any } = await api.get('site/reset') if (result.success) $toast.success(t('setting.site.resetSuccess')) else $toast.error(t('setting.site.resetFailed')) - - resetSitesDisabled.value = false - resetSitesText.value = t('setting.site.resetSites') } catch (error) { console.log(error) + $toast.error(t('setting.site.resetFailed')) + } finally { + resetSitesDisabled.value = false + resetSitesText.value = t('setting.site.resetSites') } } @@ -103,7 +104,9 @@ async function loadSiteSettings() { // 将API返回的值赋值给SystemSettings for (const sectionKey of Object.keys(siteSetting.value) as Array) { Object.keys(siteSetting.value[sectionKey]).forEach((key: string) => { - if (result.data.hasOwnProperty(key)) (siteSetting.value[sectionKey] as any)[key] = result.data[key] + if (Object.prototype.hasOwnProperty.call(result.data, key)) { + Reflect.set(siteSetting.value[sectionKey], key, result.data[key]) + } }) } } diff --git a/src/views/setting/AccountSettingSubscribe.vue b/src/views/setting/AccountSettingSubscribe.vue index d16202e3..1b7c9bf9 100644 --- a/src/views/setting/AccountSettingSubscribe.vue +++ b/src/views/setting/AccountSettingSubscribe.vue @@ -119,6 +119,7 @@ async function saveSelectedRssSites() { else $toast.error(t('setting.subscribe.saveFailed')) } catch (error) { console.log(error) + $toast.error(t('setting.subscribe.saveFailed')) } } @@ -130,7 +131,9 @@ async function loadSystemSettings() { // 将API返回的值赋值给SystemSettings for (const sectionKey of Object.keys(SystemSettings.value) as Array) { Object.keys(SystemSettings.value[sectionKey]).forEach((key: string) => { - if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key] + if (Object.prototype.hasOwnProperty.call(result.data, key)) { + Reflect.set(SystemSettings.value[sectionKey], key, result.data[key]) + } }) } } @@ -147,7 +150,9 @@ async function saveSystemSetting(value: { [key: string]: any }) { if (result.success) { return true } - } catch (error) {} + } catch { + return false + } return false } @@ -185,6 +190,7 @@ async function saveSubscribeSetting() { } else $toast.error(t('setting.subscribe.settingsSaveFailed')) } catch (error) { console.log(error) + $toast.error(t('setting.subscribe.settingsSaveFailed')) } } diff --git a/src/views/setting/__tests__/AccountSettingSearch.spec.ts b/src/views/setting/__tests__/AccountSettingSearch.spec.ts new file mode 100644 index 00000000..82de9b51 --- /dev/null +++ b/src/views/setting/__tests__/AccountSettingSearch.spec.ts @@ -0,0 +1,181 @@ +import AccountSettingSearch from '@/views/setting/AccountSettingSearch.vue' +import { fireEvent, screen, waitFor, within } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), + useSilentSettingRefresh: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: { + get: mocks.apiGet, + post: mocks.apiPost, + }, +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ + error: mocks.toastError, + success: mocks.toastSuccess, + }), +})) + +vi.mock('@/composables/useSilentSettingRefresh', () => ({ + useSilentSettingRefresh: mocks.useSilentSettingRefresh, +})) + +function mockLoadedSettings() { + mocks.apiGet.mockImplementation((endpoint: string) => { + if (endpoint === 'site/') { + return [ + { id: 1, name: 'Alpha', is_active: true }, + { id: 2, name: 'Disabled', is_active: false }, + ] + } + if (endpoint === 'system/setting/UserFilterRuleGroups') { + return { success: true, data: { value: [{ name: 'HDR' }, { name: 'Remux' }] } } + } + if (endpoint === 'system/setting/public/IndexerSites') { + return { success: true, data: { value: [1] } } + } + if (endpoint === 'system/setting/SEARCH_SOURCE') { + return { success: true, data: { value: 'douban,bangumi' } } + } + if (endpoint === 'system/setting/SearchFilterRuleGroups') { + return { success: true, data: { value: ['HDR'] } } + } + if (endpoint === 'system/env') { + return { + success: true, + data: { + SEARCH_MULTIPLE_NAME: true, + DOWNLOAD_SUBTITLE: true, + AUTO_DOWNLOAD_USER: '42', + UNRELATED: 'ignored', + }, + } + } + throw new Error(`Unexpected GET ${endpoint}`) + }) +} + +async function renderSettings() { + return renderWithProviders(AccountSettingSearch) +} + +function getCard(title: string) { + const card = screen.getByText(title).closest('.v-card') + expect(card).not.toBeNull() + return within(card as HTMLElement) +} + +describe('AccountSettingSearch', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + mocks.apiGet.mockReset() + mocks.apiPost.mockReset() + mocks.toastError.mockReset() + mocks.toastSuccess.mockReset() + mocks.useSilentSettingRefresh.mockReset() + mockLoadedSettings() + mocks.apiPost.mockResolvedValue({ success: true }) + }) + + it('loads declared settings, filters disabled sites, and registers active-only refresh', async () => { + const { rerender } = await renderSettings() + + expect(await screen.findByDisplayValue('MOVIEPILOT')).toBeInTheDocument() + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.queryByText('Disabled')).not.toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: '多名称资源搜索' })).toBeChecked() + expect(screen.getByRole('checkbox', { name: '下载站点字幕' })).toBeChecked() + + 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('saves media sources, rule groups, and only the owned environment keys', async () => { + await renderSettings() + await screen.findByDisplayValue('MOVIEPILOT') + + await fireEvent.update(screen.getByLabelText('下载任务标签'), 'UPDATED') + await fireEvent.update(screen.getByLabelText('远程搜索自动下载用户'), '84') + await fireEvent.click(screen.getByRole('checkbox', { name: '多名称资源搜索' })) + await fireEvent.click(screen.getByRole('checkbox', { name: '下载站点字幕' })) + + await fireEvent.click(getCard('基础设置').getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'system/setting/SEARCH_SOURCE', 'douban,bangumi') + expect(mocks.apiPost).toHaveBeenNthCalledWith(2, 'system/setting/SearchFilterRuleGroups', ['HDR']) + expect(mocks.apiPost).toHaveBeenNthCalledWith(3, 'system/env', { + SEARCH_MULTIPLE_NAME: false, + DOWNLOAD_SUBTITLE: false, + AUTO_DOWNLOAD_USER: '84', + TORRENT_TAG: 'UPDATED', + }) + }) + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('搜索基础设置保存成功')) + }) + + it('stops the combined save when the media source is rejected', async () => { + mocks.apiPost.mockResolvedValueOnce({ success: false, message: 'source rejected' }) + await renderSettings() + + await fireEvent.click(getCard('基础设置').getByRole('button', { name: '保存' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('媒体搜索数据源保存失败:source rejected!')) + expect(mocks.apiPost).toHaveBeenCalledTimes(1) + expect(mocks.toastSuccess).not.toHaveBeenCalled() + }) + + it('reports an environment business failure and an HTTP failure without reporting success', async () => { + mocks.apiPost + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false }) + await renderSettings() + + await fireEvent.click(getCard('基础设置').getByRole('button', { name: '保存' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('搜索基础设置保存失败!')) + expect(mocks.toastSuccess).not.toHaveBeenCalled() + + mocks.toastError.mockReset() + mocks.apiPost.mockReset() + mocks.apiPost.mockRejectedValueOnce(new Error('offline')) + await fireEvent.click(getCard('基础设置').getByRole('button', { name: '保存' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('搜索基础设置保存失败!')) + expect(mocks.toastSuccess).not.toHaveBeenCalled() + }) + + it('saves selected search sites and exposes business and HTTP failures', async () => { + await renderSettings() + await screen.findByText('Alpha') + const saveSites = getCard('搜索站点').getByRole('button', { name: '保存' }) + + await fireEvent.click(screen.getByText('Alpha')) + + await fireEvent.click(saveSites) + await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/IndexerSites', [])) + expect(mocks.toastSuccess).toHaveBeenCalledWith('搜索站点保存成功') + + mocks.apiPost.mockReset() + mocks.toastSuccess.mockReset() + mocks.apiPost.mockResolvedValueOnce({ success: false }) + await fireEvent.click(saveSites) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('搜索站点保存失败!')) + + mocks.apiPost.mockReset() + mocks.toastError.mockReset() + mocks.apiPost.mockRejectedValueOnce(new Error('offline')) + await fireEvent.click(saveSites) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('搜索站点保存失败!')) + }) +}) diff --git a/src/views/setting/__tests__/AccountSettingSite.spec.ts b/src/views/setting/__tests__/AccountSettingSite.spec.ts new file mode 100644 index 00000000..27c8e04e --- /dev/null +++ b/src/views/setting/__tests__/AccountSettingSite.spec.ts @@ -0,0 +1,210 @@ +import AccountSettingSite from '@/views/setting/AccountSettingSite.vue' +import userEvent from '@testing-library/user-event' +import { fireEvent, screen, waitFor, within } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), + useSilentSettingRefresh: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: { get: mocks.apiGet, post: mocks.apiPost }, +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), +})) + +vi.mock('@/composables/useSilentSettingRefresh', () => ({ + useSilentSettingRefresh: mocks.useSilentSettingRefresh, +})) + +function mockLoadedSettings() { + mocks.apiGet.mockImplementation((endpoint: string) => { + if (endpoint === 'system/env') { + return { + success: true, + data: { + COOKIECLOUD_HOST: 'https://cookies.example.com', + COOKIECLOUD_KEY: 'key', + COOKIECLOUD_PASSWORD: 'password', + COOKIECLOUD_AUTH_HEADER: 'header', + COOKIECLOUD_INTERVAL: 60, + COOKIECLOUD_ENABLE_LOCAL: false, + COOKIECLOUD_BLACKLIST: 'blocked.example.com', + SITEDATA_REFRESH_INTERVAL: 6, + SITE_MESSAGE: true, + BROWSER_EMULATION: 'flaresolverr', + OCR_HOST: 'https://ocr.example.com', + FLARESOLVERR_URL: 'http://solver:8191', + UNRELATED: 'ignored', + }, + } + } + if (endpoint === 'site/reset') return { success: true } + throw new Error(`Unexpected GET ${endpoint}`) + }) +} + +async function renderSettings() { + return renderWithProviders(AccountSettingSite) +} + +function getCard(title: string) { + const card = screen.getByText(title).closest('.v-card') + expect(card).not.toBeNull() + return within(card as HTMLElement) +} + +describe('AccountSettingSite', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + mocks.apiGet.mockReset() + mocks.apiPost.mockReset() + mocks.toastError.mockReset() + mocks.toastSuccess.mockReset() + mocks.useSilentSettingRefresh.mockReset() + mockLoadedSettings() + mocks.apiPost.mockResolvedValue({ success: true }) + }) + + it('loads only declared site settings and follows active refresh state', async () => { + const { rerender } = await renderSettings() + + expect(await screen.findByDisplayValue('https://cookies.example.com')).toBeInTheDocument() + expect(screen.getByDisplayValue('https://ocr.example.com')).toBeInTheDocument() + expect(screen.getByDisplayValue('http://solver:8191')).toBeInTheDocument() + expect(screen.getByLabelText('搜索资源获取页数')).toHaveValue(1) + expect(screen.getByRole('checkbox', { name: '阅读站点消息' })).toBeChecked() + + 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('saves CookieCloud and site sections with only their owned keys', async () => { + const user = userEvent.setup() + const updatedCredential = 'updated-value' + await renderSettings() + await screen.findByDisplayValue('https://cookies.example.com') + + await fireEvent.click(screen.getByRole('checkbox', { name: '启用本地CookieCloud服务器' })) + await fireEvent.update(screen.getByLabelText('服务地址'), 'https://new-cookies.example.com') + await fireEvent.update(screen.getByLabelText('用户KEY'), 'new-key') + await fireEvent.update(screen.getByLabelText('端对端加密密码'), updatedCredential) + await fireEvent.update(screen.getByLabelText('上传认证 Header'), 'new-header') + await fireEvent.update(screen.getByLabelText('同步域名黑名单'), 'new-blocked.example.com') + await user.click(screen.getByLabelText('自动同步间隔')) + await user.click(await screen.findByRole('option', { name: '每6小时' })) + + await fireEvent.click(getCard('站点同步').getByRole('button', { name: '保存' })) + await waitFor(() => { + expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'system/env', { + COOKIECLOUD_HOST: 'https://new-cookies.example.com', + COOKIECLOUD_KEY: 'new-key', + COOKIECLOUD_PASSWORD: updatedCredential, + COOKIECLOUD_AUTH_HEADER: 'new-header', + COOKIECLOUD_INTERVAL: 360, + COOKIECLOUD_ENABLE_LOCAL: true, + COOKIECLOUD_BLACKLIST: 'new-blocked.example.com', + }) + }) + + await fireEvent.update(screen.getByLabelText('搜索资源获取页数'), '4') + await fireEvent.update(screen.getByLabelText('验证码识别服务器'), 'https://new-ocr.example.com') + await fireEvent.update(screen.getByLabelText('FlareSolverr 服务地址'), 'http://new-solver:8191') + await fireEvent.click(screen.getByRole('checkbox', { name: '阅读站点消息' })) + await user.click(screen.getByLabelText('站点数据刷新间隔')) + await user.click(await screen.findByRole('option', { name: '每12小时' })) + await user.click(screen.getByLabelText('浏览器仿真')) + await user.click(await screen.findByRole('option', { name: 'CloakBrowser' })) + expect(screen.queryByLabelText('FlareSolverr 服务地址')).not.toBeInTheDocument() + + await fireEvent.click(getCard('站点选项').getByRole('button', { name: '保存' })) + await waitFor(() => { + expect(mocks.apiPost).toHaveBeenNthCalledWith(2, 'system/env', { + SITEDATA_REFRESH_INTERVAL: 12, + SITE_MESSAGE: false, + SEARCH_RESOURCE_PAGES: 4, + BROWSER_EMULATION: 'cloakbrowser', + OCR_HOST: 'https://new-ocr.example.com', + FLARESOLVERR_URL: 'http://new-solver:8191', + }) + }) + expect(mocks.toastSuccess).toHaveBeenCalledTimes(2) + }) + + it('exposes business and HTTP failures when saving a section', async () => { + mocks.apiPost.mockResolvedValueOnce({ success: false }) + await renderSettings() + const save = getCard('站点同步').getByRole('button', { name: '保存' }) + + await fireEvent.click(save) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('站点设置保存失败!')) + + mocks.toastError.mockReset() + mocks.apiPost.mockRejectedValueOnce(new Error('offline')) + await fireEvent.click(save) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalled()) + expect(mocks.toastSuccess).not.toHaveBeenCalled() + }) + + it('requires confirmation and restores the reset action after success or business failure', async () => { + const user = userEvent.setup() + await renderSettings() + const resetCard = getCard('站点重置') + const resetButton = resetCard.getByRole('button', { name: '重置站点数据' }) + let resolveReset: ((value: { success: boolean }) => void) | undefined + mocks.apiGet.mockImplementation((endpoint: string) => { + if (endpoint === 'site/reset') { + return new Promise(resolve => { + resolveReset = resolve + }) + } + throw new Error(`Unexpected GET ${endpoint}`) + }) + expect(resetButton).toBeDisabled() + + await user.click(resetCard.getByRole('checkbox', { name: '确认删除所有站点数据并重新同步。' })) + expect(resetButton).toBeEnabled() + await user.click(resetButton) + await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('site/reset')) + expect(resetCard.getByRole('button', { name: '正在重置...' })).toBeDisabled() + await user.click(resetCard.getByRole('button', { name: '正在重置...' })) + expect(mocks.apiGet.mock.calls.filter(([endpoint]) => endpoint === 'site/reset')).toHaveLength(1) + + resolveReset?.({ success: true }) + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('站点重置成功,请等待CookieCloud同步完成!')) + expect(resetCard.getByRole('button', { name: '重置站点数据' })).toBeEnabled() + + mocks.apiGet.mockImplementation((endpoint: string) => { + if (endpoint === 'site/reset') return { success: false } + return mockLoadedSettings() + }) + mocks.toastSuccess.mockReset() + await user.click(resetCard.getByRole('button', { name: '重置站点数据' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('站点重置失败!')) + expect(resetCard.getByRole('button', { name: '重置站点数据' })).toBeEnabled() + }) + + it('restores the reset action and shows failure after an HTTP error', async () => { + await renderSettings() + const resetCard = getCard('站点重置') + await fireEvent.click(resetCard.getByRole('checkbox', { name: '确认删除所有站点数据并重新同步。' })) + mocks.apiGet.mockImplementation((endpoint: string) => { + if (endpoint === 'site/reset') throw new Error('offline') + throw new Error(`Unexpected GET ${endpoint}`) + }) + + await fireEvent.click(resetCard.getByRole('button', { name: '重置站点数据' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('站点重置失败!')) + expect(resetCard.getByRole('button', { name: '重置站点数据' })).toBeEnabled() + }) +}) diff --git a/src/views/setting/__tests__/AccountSettingSubscribe.spec.ts b/src/views/setting/__tests__/AccountSettingSubscribe.spec.ts new file mode 100644 index 00000000..5ce66c32 --- /dev/null +++ b/src/views/setting/__tests__/AccountSettingSubscribe.spec.ts @@ -0,0 +1,173 @@ +import AccountSettingSubscribe from '@/views/setting/AccountSettingSubscribe.vue' +import userEvent from '@testing-library/user-event' +import { fireEvent, screen, waitFor, within } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), + useSilentSettingRefresh: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: { get: mocks.apiGet, post: mocks.apiPost }, +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), +})) + +vi.mock('@/composables/useSilentSettingRefresh', () => ({ + useSilentSettingRefresh: mocks.useSilentSettingRefresh, +})) + +function mockLoadedSettings() { + mocks.apiGet.mockImplementation((endpoint: string) => { + if (endpoint === 'site/') { + return [ + { id: 3, name: 'RSS Alpha', is_active: true }, + { id: 4, name: 'RSS Disabled', is_active: false }, + ] + } + if (endpoint === 'system/setting/UserFilterRuleGroups') { + return { success: true, data: { value: [{ name: 'HDR' }, { name: 'Remux' }] } } + } + if (endpoint === 'system/setting/RssSites') return { success: true, data: { value: [3] } } + if (endpoint === 'system/setting/SubscribeFilterRuleGroups') { + return { success: true, data: { value: ['HDR'] } } + } + if (endpoint === 'system/setting/BestVersionFilterRuleGroups') { + return { success: true, data: { value: ['Remux'] } } + } + if (endpoint === 'system/env') { + return { + success: true, + data: { + SUBSCRIBE_SEARCH: true, + SUBSCRIBE_SEARCH_INTERVAL: 72, + SUBSCRIBE_RSS_INTERVAL: 20, + LOCAL_EXISTS_SEARCH: true, + UNRELATED: 'ignored', + }, + } + } + throw new Error(`Unexpected GET ${endpoint}`) + }) +} + +async function renderSettings() { + return renderWithProviders(AccountSettingSubscribe) +} + +function getCard(title: string) { + const card = screen.getByText(title).closest('.v-card') + expect(card).not.toBeNull() + return within(card as HTMLElement) +} + +describe('AccountSettingSubscribe', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + mocks.apiGet.mockReset() + mocks.apiPost.mockReset() + mocks.toastError.mockReset() + mocks.toastSuccess.mockReset() + mocks.useSilentSettingRefresh.mockReset() + mockLoadedSettings() + mocks.apiPost.mockResolvedValue({ success: true }) + }) + + it('loads owned settings, filters inactive sites, and follows active refresh state', async () => { + const { rerender } = await renderSettings() + + expect(await screen.findByText('RSS Alpha')).toBeInTheDocument() + expect(screen.queryByText('RSS Disabled')).not.toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: '订阅定时搜索' })).toBeChecked() + expect(screen.getByRole('checkbox', { name: '检查文件系统资源' })).toBeChecked() + expect(screen.getByLabelText('订阅模式')).toBeInTheDocument() + expect(screen.getByLabelText('订阅搜索时间间隔')).toBeInTheDocument() + + 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('saves rule groups and only the owned subscription environment keys', async () => { + const user = userEvent.setup() + await renderSettings() + await screen.findByText('RSS Alpha') + + await user.click(screen.getByRole('checkbox', { name: '订阅定时搜索' })) + await user.click(screen.getByRole('checkbox', { name: '检查文件系统资源' })) + + await user.click(screen.getByLabelText('订阅模式')) + await user.click(await screen.findByText('自动')) + await user.click(screen.getByLabelText('站点RSS周期')) + await user.click(await screen.findByText('半小时')) + await user.click(screen.getByLabelText('订阅优先级规则组')) + await user.click(await screen.findByRole('option', { name: 'Remux' })) + + await user.click(getCard('基础设置').getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'system/setting/SubscribeFilterRuleGroups', ['HDR', 'Remux']) + expect(mocks.apiPost).toHaveBeenNthCalledWith(2, 'system/setting/BestVersionFilterRuleGroups', ['Remux']) + expect(mocks.apiPost).toHaveBeenNthCalledWith(3, 'system/env', { + SUBSCRIBE_MODE: 'spider', + SUBSCRIBE_SEARCH: false, + SUBSCRIBE_SEARCH_INTERVAL: 72, + SUBSCRIBE_RSS_INTERVAL: 30, + LOCAL_EXISTS_SEARCH: false, + }) + }) + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('订阅基础设置保存成功')) + }) + + it('reports environment business and HTTP failures from the combined settings save', async () => { + mocks.apiPost + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false }) + await renderSettings() + const save = getCard('基础设置').getByRole('button', { name: '保存' }) + + await fireEvent.click(save) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('订阅基础设置保存失败!')) + expect(mocks.apiPost).toHaveBeenNthCalledWith(3, 'system/env', expect.objectContaining({ SUBSCRIBE_MODE: 'auto' })) + expect(mocks.toastSuccess).not.toHaveBeenCalled() + + mocks.apiPost.mockReset() + mocks.toastError.mockReset() + mocks.apiPost.mockRejectedValueOnce(new Error('offline')) + await fireEvent.click(save) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('订阅基础设置保存失败!')) + }) + + it('saves selected RSS sites and exposes business and HTTP failures', async () => { + await renderSettings() + await screen.findByText('RSS Alpha') + const saveSites = getCard('订阅站点').getByRole('button', { name: '保存' }) + + await fireEvent.click(screen.getByText('RSS Alpha')) + + await fireEvent.click(saveSites) + await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/RssSites', [])) + expect(mocks.toastSuccess).toHaveBeenCalledWith('订阅站点保存成功') + + mocks.apiPost.mockReset() + mocks.toastSuccess.mockReset() + mocks.apiPost.mockResolvedValueOnce({ success: false }) + await fireEvent.click(saveSites) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('订阅站点保存失败!')) + + mocks.apiPost.mockReset() + mocks.toastError.mockReset() + mocks.apiPost.mockRejectedValueOnce(new Error('offline')) + await fireEvent.click(saveSites) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('订阅站点保存失败!')) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index a3744f1e..a60baf5a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -388,6 +388,9 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/utils/mediaStatusCache.ts', 'src/utils/searchStream.ts', 'src/views/site/SiteCardListView.vue', + 'src/views/setting/AccountSettingSearch.vue', + 'src/views/setting/AccountSettingSite.vue', + 'src/views/setting/AccountSettingSubscribe.vue', 'src/views/user/UserListView.vue', 'src/views/user/UserProfileView.vue', 'src/views/reorganize/DownloadingListView.vue', @@ -444,6 +447,24 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 80, statements: 80, }, + 'src/views/setting/AccountSettingSearch.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/views/setting/AccountSettingSite.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/views/setting/AccountSettingSubscribe.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, 'src/components/cards/TorrentCard.vue': { branches: 75, functions: 80,