diff --git a/docs/testing.md b/docs/testing.md index 4de6cb37..474ac14b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -78,7 +78,7 @@ tests/ Vitest 只收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。 -当前核心覆盖范围在 `vite.config.ts` 的 `coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 80%,Branches 不低于 75%。覆盖率报告写入 `coverage/`。 +当前核心覆盖范围在 `vite.config.ts` 的 `coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%,Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%,Branches 不低于 75%。覆盖率报告写入 `coverage/`。 ## 命令与 CI diff --git a/src/components/dialog/SubscribeEditDialog.vue b/src/components/dialog/SubscribeEditDialog.vue index b6189a8a..5a1142a7 100644 --- a/src/components/dialog/SubscribeEditDialog.vue +++ b/src/components/dialog/SubscribeEditDialog.vue @@ -10,6 +10,17 @@ import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constant import { useUserStore } from '@/stores' import { buildUserPermissionContext, hasPermission } from '@/utils/permission' import { formatSeason } from '@/@core/utils/formatters' + +// 从变更请求异常中提取可展示消息,并为非标准错误提供稳定兜底。 +function getRequestErrorMessage(error: unknown, fallback: string) { + if (typeof error === 'object' && error !== null) { + const responseMessage = (error as { response?: { data?: { message?: unknown } } }).response?.data?.message + if (typeof responseMessage === 'string' && responseMessage) return responseMessage + } + if (error instanceof Error && error.message) return error.message + return fallback +} + // i18n const { t } = useI18n() const userStore = useUserStore() @@ -104,6 +115,12 @@ function getSubscribeDisplayName() { return `${name} ${formatSeason(season.toString())}` } +function getDefaultSubscribeTypeName() { + if (props.type === '电影') return t('mediaType.movie') + if (props.type === '电视剧') return t('mediaType.tv') + return props.type ?? '' +} + // 剧集组选项属性 function episodeGroupItemProps(item: { title: string; subtitle: string }) { return { @@ -162,18 +179,30 @@ const filterRuleGroupOptions = computed(() => { // 调用API修改订阅 async function updateSubscribeInfo() { + const displayName = getSubscribeDisplayName() try { const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value) // 提示 if (result.success) { - $toast.success(`${getSubscribeDisplayName()} 更新成功!`) + $toast.success(t('dialog.subscribeEdit.updateSuccess', { name: displayName })) // 通知父组件刷新 - emit('save') + emit('save', subscribeForm.value) } else { - $toast.error(`${getSubscribeDisplayName()} 更新失败:${result.message}!`) + $toast.error( + t('dialog.subscribeEdit.updateFailed', { + name: displayName, + message: result.message ?? t('subscribe.requestFailed'), + }), + ) } } catch (e) { console.log(e) + $toast.error( + t('dialog.subscribeEdit.updateFailed', { + name: displayName, + message: getRequestErrorMessage(e, t('subscribe.requestFailed')), + }), + ) } } @@ -181,19 +210,33 @@ async function updateSubscribeInfo() { async function saveDefaultSubscribeConfig() { if (!canAdmin.value) return + const typeName = getDefaultSubscribeTypeName() try { let subscribe_config_url = '' if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig' else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig' const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value) - if (result.success) $toast.success(`${props.type}订阅默认规则保存成功`) - else $toast.error(`${props.type}订阅默认规则保存失败!`) - - // 通知父组件刷新 - emit('save') + if (result.success) { + $toast.success(t('dialog.subscribeEdit.defaultSaveSuccess', { type: typeName })) + // 通知父组件刷新 + emit('save', subscribeForm.value) + } else { + $toast.error( + t('dialog.subscribeEdit.defaultSaveFailed', { + type: typeName, + message: result.message ?? t('subscribe.requestFailed'), + }), + ) + } } catch (error) { console.log(error) + $toast.error( + t('dialog.subscribeEdit.defaultSaveFailed', { + type: typeName, + message: getRequestErrorMessage(error, t('subscribe.requestFailed')), + }), + ) } } @@ -262,16 +305,28 @@ async function removeSubscribe() { }) if (!isConfirmed) return + const displayName = getSubscribeDisplayName() try { const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`) if (result.success) { - $toast.success(`订阅 ${getSubscribeDisplayName()} 已取消!`) + $toast.success(`${displayName} ${t('subscribe.cancelSuccess')}`) // 通知父组件刷新 emit('remove') + } else { + $toast.error( + `${displayName} ${t('subscribe.cancelFailed', { + message: result.message ?? t('subscribe.requestFailed'), + })}`, + ) } } catch (e) { console.log(e) + $toast.error( + `${displayName} ${t('subscribe.cancelFailed', { + message: getRequestErrorMessage(e, t('subscribe.requestFailed')), + })}`, + ) } } @@ -289,8 +344,10 @@ async function loadDownloadDirectories() { // 保存目录下拉框 const targetDirectories = computed(() => { - // 去重后的下载目录 - return downloadDirectories.value.map(item => item.download_path) + const paths = downloadDirectories.value + .map(item => item.download_path?.trim()) + .filter((path): path is string => Boolean(path)) + return [...new Set(paths)] }) // 仅电视剧订阅支持全集洗版,电影保持原有洗版逻辑 diff --git a/src/components/dialog/__tests__/SubscribeEditDialog.spec.ts b/src/components/dialog/__tests__/SubscribeEditDialog.spec.ts new file mode 100644 index 00000000..68134f60 --- /dev/null +++ b/src/components/dialog/__tests__/SubscribeEditDialog.spec.ts @@ -0,0 +1,451 @@ +import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue' +import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { + createSubscribe, + createSubscribeDirectory, + createSubscribeDownloader, + createSubscribeRuleGroup, + createSubscribeSite, +} from '@tests/support/factories/subscribe' +import { + defaultSubscribeConfigHandler, + deleteSubscribeByIdHandler, + saveDefaultSubscribeConfigHandler, + subscribeApiUrls, + subscribeDetailsHandler, + subscribeDialogOptionHandlers, + type SubscribeDialogOptions, + type SubscribeMediaType, + updateSubscribeHandler, +} from '@tests/support/msw/handlers/subscribe' +import { server } from '@tests/support/msw/server' +import { renderWithProviders } from '@tests/support/render' +import { HttpResponse, http } from 'msw' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + confirm: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), +})) + +vi.mock('@/composables/useConfirm', () => ({ + useConfirm: () => mocks.confirm, +})) + +interface DialogProps { + default?: boolean + subid?: number + type?: SubscribeMediaType +} + +async function renderDialog(props: DialogProps, superUser = true) { + const events = { + close: vi.fn(), + remove: vi.fn(), + save: vi.fn(), + } + const result = await renderWithProviders(SubscribeEditDialog, { + initialState: { + user: { + superUser, + userName: superUser ? 'admin' : 'member', + }, + }, + props: { + modelValue: true, + ...props, + onClose: events.close, + onRemove: events.remove, + onSave: events.save, + }, + global: { + components: { + VDialogCloseBtn: DialogCloseBtn, + }, + }, + }) + return { ...result, events } +} + +function useDialogOptions(options: SubscribeDialogOptions = {}) { + server.use(...subscribeDialogOptionHandlers(options)) +} + +describe('SubscribeEditDialog', () => { + beforeEach(() => { + mocks.confirm.mockResolvedValue(true) + }) + + it('loads a TV subscription, normalizes flags, and exposes episode groups', async () => { + const record = createSubscribe({ + best_version: 1, + best_version_full: 1, + id: 801, + name: '季度测试剧', + search_imdbid: 0, + season: 2, + tmdbid: 8010, + type: '电视剧', + }) + const episodeGroupsRequested = vi.fn() + server.use(subscribeDetailsHandler(801, record)) + useDialogOptions({ + episodeGroups: [{ episode_count: 24, group_count: 2, id: 99, name: '官方特别排序' }], + onEpisodeGroups: episodeGroupsRequested, + tmdbId: 8010, + }) + const user = userEvent.setup() + await renderDialog({ subid: 801 }) + + expect(await screen.findByText('季度测试剧 S02')).toBeInTheDocument() + expect(screen.getByLabelText('洗版')).toBeChecked() + expect(screen.getByLabelText('全集洗版')).toBeChecked() + expect(screen.getByLabelText('使用 ImdbID 搜索')).not.toBeChecked() + await waitFor(() => expect(episodeGroupsRequested).toHaveBeenCalledOnce()) + + await user.click(screen.getByRole('tab', { name: '进阶' })) + await user.click(screen.getByLabelText('指定剧集组')) + expect(await screen.findByText('官方特别排序')).toBeInTheDocument() + expect(screen.getByText('2 季 • 24 集')).toBeInTheDocument() + }) + + it('keeps movie titles free of season suffixes and skips episode groups', async () => { + const record = createSubscribe({ id: 802, name: '电影测试项', season: undefined, tmdbid: 8020, type: '电影' }) + const episodeGroupsRequested = vi.fn() + server.use(subscribeDetailsHandler(802, record)) + useDialogOptions({ onEpisodeGroups: episodeGroupsRequested, tmdbId: 8020 }) + await renderDialog({ subid: 802 }) + + expect(await screen.findByText('电影测试项')).toBeInTheDocument() + expect(screen.queryByText(/电影测试项 S\d+/)).not.toBeInTheDocument() + expect(episodeGroupsRequested).not.toHaveBeenCalled() + }) + + it('shows enabled sites and stable downloader, directory, and rule options', async () => { + const activeSite = createSubscribeSite({ id: 1, is_active: true, name: '启用站点' }) + const inactiveSite = createSubscribeSite({ id: 2, is_active: false, name: '停用站点' }) + const requests = { + directories: vi.fn(), + downloaders: vi.fn(), + rules: vi.fn(), + sites: vi.fn(), + } + server.use(defaultSubscribeConfigHandler('电影', createSubscribe({ id: 0, type: '电影' }))) + useDialogOptions({ + directories: [ + createSubscribeDirectory({ download_path: '/downloads', name: '目录一' }), + createSubscribeDirectory({ download_path: '/downloads', name: '目录二' }), + createSubscribeDirectory({ download_path: undefined, name: '空目录' }), + ], + downloaders: [createSubscribeDownloader({ name: '下载器 A' })], + filterRuleGroups: [createSubscribeRuleGroup({ name: '高优先级' })], + onDirectories: requests.directories, + onDownloaders: requests.downloaders, + onFilterRuleGroups: requests.rules, + onSites: requests.sites, + sites: [activeSite, inactiveSite], + }) + const user = userEvent.setup() + await renderDialog({ default: true, type: '电影' }) + await waitFor(() => expect(requests.sites).toHaveBeenCalledOnce()) + await waitFor(() => expect(requests.downloaders).toHaveBeenCalledOnce()) + await waitFor(() => expect(requests.directories).toHaveBeenCalledOnce()) + await waitFor(() => expect(requests.rules).toHaveBeenCalledOnce()) + + await user.click(screen.getByLabelText('订阅站点')) + expect(await screen.findByText('启用站点')).toBeInTheDocument() + expect(screen.queryByText('停用站点')).not.toBeInTheDocument() + await user.keyboard('{Escape}') + + await user.click(screen.getByLabelText('下载器')) + expect(await screen.findByText('下载器 A')).toBeInTheDocument() + expect(screen.getAllByText('默认').length).toBeGreaterThanOrEqual(1) + await user.keyboard('{Escape}') + + await user.click(screen.getByLabelText('保存路径')) + expect(await screen.findAllByText('/downloads')).toHaveLength(1) + expect(screen.queryByText('undefined')).not.toBeInTheDocument() + await user.keyboard('{Escape}') + + await user.click(screen.getByRole('tab', { name: '进阶' })) + await user.click(screen.getByLabelText('优先级规则组')) + expect(await screen.findByText('高优先级')).toBeInTheDocument() + }) + + it('allows non-admin users to read public defaults but not private rules or save them', async () => { + const configRequested = vi.fn() + const rulesRequested = vi.fn() + const saved = vi.fn() + server.use( + defaultSubscribeConfigHandler('电视剧', createSubscribe({ id: 0, type: '电视剧' }), 200, configRequested), + saveDefaultSubscribeConfigHandler('电视剧', { success: true }, 200, saved), + ) + useDialogOptions({ onFilterRuleGroups: rulesRequested }) + const { events } = await renderDialog({ default: true, type: '电视剧' }, false) + + await waitFor(() => expect(configRequested).toHaveBeenCalledOnce()) + expect(rulesRequested).not.toHaveBeenCalled() + await fireEvent.click(screen.getByRole('button', { name: '保存' })) + + expect(saved).not.toHaveBeenCalled() + expect(events.save).not.toHaveBeenCalled() + }) + + it.each(['电影', '电视剧'] as const)('loads and saves %s default configuration as an administrator', async type => { + const configRequested = vi.fn() + const saved = vi.fn() + server.use( + defaultSubscribeConfigHandler(type, createSubscribe({ id: 0, show_edit_dialog: false, type }), 200, configRequested), + saveDefaultSubscribeConfigHandler(type, { success: true }, 200, saved), + ) + useDialogOptions() + const user = userEvent.setup() + const { events } = await renderDialog({ default: true, type }) + await waitFor(() => expect(configRequested).toHaveBeenCalledOnce()) + await waitFor(() => expect(screen.getByLabelText('订阅时编辑更多规则')).not.toBeChecked()) + + await user.click(screen.getByLabelText('订阅时编辑更多规则')) + + await fireEvent.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(saved).toHaveBeenCalledOnce()) + expect(saved.mock.calls[0][0]).toMatchObject({ show_edit_dialog: true, type }) + expect(events.save).toHaveBeenCalledOnce() + expect(mocks.toastSuccess).toHaveBeenCalledWith(`${type}订阅默认规则保存成功`) + }) + + it('submits the complete TV editing form and exposes the close action', async () => { + const record = createSubscribe({ + best_version: 1, + best_version_full: 0, + id: 809, + name: '完整表单测试剧', + search_imdbid: 0, + season: 1, + tmdbid: 8090, + type: '电视剧', + }) + const updated = vi.fn() + server.use(subscribeDetailsHandler(809, record), updateSubscribeHandler({ success: true }, 200, updated)) + useDialogOptions({ + directories: [createSubscribeDirectory({ download_path: '/完整目录' })], + downloaders: [createSubscribeDownloader({ name: '完整下载器' })], + episodeGroups: [{ episode_count: 12, group_count: 1, id: 8091, name: '完整剧集组' }], + filterRuleGroups: [createSubscribeRuleGroup({ name: '完整规则组' })], + sites: [createSubscribeSite({ id: 8092, name: '完整站点' })], + tmdbId: 8090, + }) + const user = userEvent.setup() + const { events } = await renderDialog({ subid: 809 }) + await screen.findByText('完整表单测试剧 S01') + + const chooseOption = async (label: string, option: string) => { + await user.click(screen.getByLabelText(label)) + await user.click(await screen.findByText(option, {}, { timeout: 2_000 })) + } + + await user.type(screen.getByLabelText('总集数'), '24') + await user.type(screen.getByLabelText('开始集数'), '2') + await chooseOption('质量', 'Remux') + await chooseOption('分辨率', '1080p') + await chooseOption('特效', 'HDR') + await chooseOption('订阅站点', '完整站点') + await user.keyboard('{Escape}') + await chooseOption('下载器', '完整下载器') + await chooseOption('保存路径', '/完整目录') + await user.click(screen.getByLabelText('全集洗版')) + await user.click(screen.getByLabelText('使用 ImdbID 搜索')) + + await user.click(screen.getByRole('tab', { name: '进阶' })) + await user.type(screen.getByLabelText('包含(关键字、正则式)'), '国语') + await user.type(screen.getByLabelText('排除(关键字、正则式)'), '预告') + await chooseOption('优先级规则组', '完整规则组') + await user.keyboard('{Escape}') + await chooseOption('指定剧集组', '完整剧集组') + await chooseOption('指定季', '第 2 季') + await user.type(screen.getByLabelText('自定义类别'), '纪录片') + await user.type(screen.getByLabelText('自定义识别词'), '测试词 => 正式词') + + const closeButton = document.querySelector('.v-card-item button') + expect(closeButton).not.toBeNull() + await user.click(closeButton as HTMLButtonElement) + expect(events.close).toHaveBeenCalledOnce() + + await user.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(updated).toHaveBeenCalledOnce()) + expect(updated.mock.calls[0][0]).toMatchObject({ + best_version_full: true, + custom_words: '测试词 => 正式词', + downloader: '完整下载器', + effect: '[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+', + episode_group: 8091, + exclude: '预告', + filter_groups: ['完整规则组'], + include: '国语', + media_category: '纪录片', + quality: 'Remux', + resolution: '1080[pi]|x1080', + save_path: '/完整目录', + search_imdbid: true, + season: 2, + sites: [8092], + start_episode: '2', + total_episode: '24', + }) + expect(events.save).toHaveBeenCalledWith(expect.objectContaining({ season: 2 })) + }) + + it.each([ + ['business failure', 200, { message: 'rejected', success: false }, '电影订阅默认规则保存失败:rejected!'], + ['HTTP failure', 500, { message: 'server down', success: false }, '电影订阅默认规则保存失败:server down!'], + ])('keeps a default dialog open after a %s', async (_case, status, response, expectedMessage) => { + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) + server.use( + defaultSubscribeConfigHandler('电影', createSubscribe({ id: 0, type: '电影' })), + saveDefaultSubscribeConfigHandler('电影', response, status), + ) + useDialogOptions() + const { events } = await renderDialog({ default: true, type: '电影' }) + await screen.findByText('电影') + + await fireEvent.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage)) + expect(events.save).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument() + consoleLog.mockRestore() + }) + + it('updates an edited subscription and clears full-season mode when versioning is disabled', async () => { + const record = createSubscribe({ + best_version: 1, + best_version_full: 1, + id: 803, + keyword: '旧关键词', + name: '编辑测试剧', + season: 1, + tmdbid: 8030, + type: '电视剧', + }) + const updated = vi.fn() + server.use(subscribeDetailsHandler(803, record), updateSubscribeHandler({ success: true }, 200, updated)) + useDialogOptions({ tmdbId: 8030 }) + const user = userEvent.setup() + const { events } = await renderDialog({ subid: 803 }) + await screen.findByText('编辑测试剧 S01') + + const keyword = screen.getByLabelText('搜索关键词') + await user.clear(keyword) + await user.type(keyword, '新关键词') + await user.click(screen.getByLabelText('洗版')) + await waitFor(() => expect(screen.queryByLabelText('全集洗版')).not.toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(updated).toHaveBeenCalledOnce()) + expect(updated.mock.calls[0][0]).toMatchObject({ + best_version: false, + best_version_full: false, + id: 803, + keyword: '新关键词', + }) + expect(events.save).toHaveBeenCalledWith(expect.objectContaining({ id: 803, keyword: '新关键词' })) + expect(mocks.toastSuccess).toHaveBeenCalledWith('编辑测试剧 S01 更新成功!') + }) + + it.each([ + ['business failure', 200, { message: 'invalid', success: false }, '失败编辑项 更新失败:invalid!'], + ['HTTP failure', 500, { message: 'server down', success: false }, '失败编辑项 更新失败:server down!'], + ])('keeps an edit dialog usable after an update %s', async (_case, status, response, expectedMessage) => { + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) + const record = createSubscribe({ id: 804, name: '失败编辑项', tmdbid: 8040 }) + server.use(subscribeDetailsHandler(804, record), updateSubscribeHandler(response, status)) + useDialogOptions({ tmdbId: 8040 }) + const { events } = await renderDialog({ subid: 804 }) + await screen.findByText('失败编辑项') + + await fireEvent.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage)) + expect(events.save).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument() + consoleLog.mockRestore() + }) + + it('does not delete when confirmation is cancelled', async () => { + const record = createSubscribe({ id: 805, name: '保留订阅', tmdbid: 8050 }) + const deleted = vi.fn() + server.use(subscribeDetailsHandler(805, record), deleteSubscribeByIdHandler(805, { success: true }, 200, deleted)) + useDialogOptions({ tmdbId: 8050 }) + mocks.confirm.mockResolvedValue(false) + const { events } = await renderDialog({ subid: 805 }) + await screen.findByText('保留订阅') + + await fireEvent.click(screen.getByRole('button', { name: '取消订阅' })) + + expect(deleted).not.toHaveBeenCalled() + expect(events.remove).not.toHaveBeenCalled() + }) + + it('emits remove only after a successful deletion', async () => { + const record = createSubscribe({ id: 806, name: '删除订阅', tmdbid: 8060 }) + const deleted = vi.fn() + server.use(subscribeDetailsHandler(806, record), deleteSubscribeByIdHandler(806, { success: true }, 200, deleted)) + useDialogOptions({ tmdbId: 8060 }) + const { events } = await renderDialog({ subid: 806 }) + await screen.findByText('删除订阅') + + await fireEvent.click(screen.getByRole('button', { name: '取消订阅' })) + + await waitFor(() => expect(deleted).toHaveBeenCalledOnce()) + expect(events.remove).toHaveBeenCalledOnce() + expect(mocks.toastSuccess).toHaveBeenCalledWith('删除订阅 已取消订阅!') + }) + + it.each([ + ['business failure', 200, { message: 'not allowed', success: false }, '删除失败项 取消订阅失败:not allowed!'], + ['HTTP failure', 500, { message: 'server down', success: false }, '删除失败项 取消订阅失败:server down!'], + ])('keeps the subscription after a delete %s', async (_case, status, response, expectedMessage) => { + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) + const record = createSubscribe({ id: 807, name: '删除失败项', tmdbid: 8070 }) + server.use(subscribeDetailsHandler(807, record), deleteSubscribeByIdHandler(807, response, status)) + useDialogOptions({ tmdbId: 8070 }) + const { events } = await renderDialog({ subid: 807 }) + await screen.findByText('删除失败项') + + await fireEvent.click(screen.getByRole('button', { name: '取消订阅' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage)) + expect(events.remove).not.toHaveBeenCalled() + expect(screen.getByText('删除失败项')).toBeInTheDocument() + consoleLog.mockRestore() + }) + + it('remains editable when an auxiliary options request fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const record = createSubscribe({ id: 808, keyword: '仍可编辑', name: '部分失败项', tmdbid: 8080 }) + const updated = vi.fn() + server.use(subscribeDetailsHandler(808, record), updateSubscribeHandler({ success: true }, 200, updated)) + useDialogOptions({ tmdbId: 8080 }) + server.use( + http.get(subscribeApiUrls.downloaders, () => + HttpResponse.json({ message: 'unavailable', success: false }, { status: 500 }), + ), + ) + await renderDialog({ subid: 808 }) + + expect(await screen.findByDisplayValue('仍可编辑')).toBeInTheDocument() + await waitFor(() => expect(consoleError).toHaveBeenCalled()) + await fireEvent.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(updated).toHaveBeenCalledOnce()) + consoleError.mockRestore() + }) +}) diff --git a/src/composables/__tests__/useMediaSubscribe.spec.ts b/src/composables/__tests__/useMediaSubscribe.spec.ts new file mode 100644 index 00000000..2f9ea46d --- /dev/null +++ b/src/composables/__tests__/useMediaSubscribe.spec.ts @@ -0,0 +1,574 @@ +import type { MediaInfo, MediaSeason, Subscribe } from '@/api/types' +import { + getMediaSubscribeId, + getSubscribeMode, + type SeasonSubscribeModes, + type SubscribeMode, + useMediaSubscribe, +} from '@/composables/useMediaSubscribe' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { + createSubscribe, + createSubscribeMovie, + createSubscribeTv, +} from '@tests/support/factories/subscribe' +import { + createSubscribeHandler, + defaultSubscribeConfigHandler, + deleteSubscribeByMediaHandler, + querySubscribeByMediaHandler, + updateSubscribeHandler, +} from '@tests/support/msw/handlers/subscribe' +import { server } from '@tests/support/msw/server' +import { renderWithProviders } from '@tests/support/render' +import { defineComponent, ref } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + cacheStatus: vi.fn(), + confirm: vi.fn(), + doneProgress: vi.fn(), + onEditRemove: vi.fn(), + openSharedDialog: vi.fn(), + startProgress: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), +})) + +vi.mock('@/composables/useConfirm', () => ({ + useConfirm: () => mocks.confirm, +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + +vi.mock('@/api/nprogress', () => ({ + configureNProgress: vi.fn(), + doneNProgress: mocks.doneProgress, + startNProgress: mocks.startProgress, +})) + +vi.mock('@/utils/mediaStatusCache', () => ({ + setCachedMediaSubscribeStatus: (...args: unknown[]) => mocks.cacheStatus(...args), +})) + +interface MultiSeasonInput { + modes?: SubscribeMode | SeasonSubscribeModes + seasons?: MediaSeason[] + visible?: number[] +} + +interface HarnessOptions { + actionSeason?: number | null + canSubscribe?: boolean + isExists?: boolean + isSubscribed?: boolean + media?: MediaInfo + modes?: SeasonSubscribeModes + multi?: MultiSeasonInput + seasonsMap?: Record + subscribedSeasons?: number[] + useSeasonMap?: boolean +} + +async function renderSubscribeHarness(options: HarnessOptions = {}) { + const media = options.media + const actionSeason = options.actionSeason ?? (media?.type === '电视剧' ? media.season ?? 1 : null) + const Harness = defineComponent({ + name: 'MediaSubscribeHarness', + setup() { + const isSubscribed = ref(options.isSubscribed ?? false) + const seasonsSubscribed = ref>({ ...(options.seasonsMap ?? {}) }) + const subscribedSeasons = ref([...(options.subscribedSeasons ?? [])]) + const subscribedSeasonModes = ref({ ...(options.modes ?? {}) }) + const checkResult = ref('idle') + const actions = useMediaSubscribe({ + canSubscribe: () => options.canSubscribe ?? true, + getSubscribeStatusKey: season => `status:${season ?? 'all'}`, + isExists: () => options.isExists ?? false, + isSubscribed, + media: () => media, + onEditRemove: mocks.onEditRemove, + primarySeason: () => media?.season ?? null, + seasonsSubscribed: options.useSeasonMap ? seasonsSubscribed : undefined, + subscribedSeasonModes, + subscribedSeasons, + }) + + async function check() { + try { + checkResult.value = (await actions.checkSubscribe(actionSeason)) ? 'subscribed' : 'missing' + } catch { + checkResult.value = 'error' + } + } + + function alignSeasons() { + actions.subscribeSeasons( + options.multi?.seasons ?? [], + {}, + 'episode-group-1', + options.multi?.modes ?? 'normal', + options.multi?.visible ?? [], + ) + } + + return { + addBestFull: () => actions.addSubscribe(actionSeason, { best_version: 1, best_version_full: 1 }), + addNormal: () => actions.addSubscribe(actionSeason), + alignSeasons, + check, + checkResult, + handlePrimary: () => actions.handleSubscribe(), + handleSeason: () => actions.handleSubscribe(actionSeason, 'episode-group-entry'), + isSubscribed, + modes: subscribedSeasonModes, + openSeason: () => actions.openSubscribeSeasonDialog(actionSeason, 'episode-group-entry'), + remove: () => actions.removeSubscribe(actionSeason), + seasons: subscribedSeasons, + seasonsMap: seasonsSubscribed, + } + }, + template: ` + + + + + + + + + {{ String(isSubscribed) }} + {{ JSON.stringify(seasons) }} + {{ JSON.stringify(seasonsMap) }} + {{ JSON.stringify(modes) }} + {{ checkResult }} + `, + }) + + return renderWithProviders(Harness, { + initialState: { + user: { + superUser: false, + userName: 'tester', + }, + }, + }) +} + +function getDialogCall(index = 0) { + const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [ + unknown, + Record, + Record unknown>, + Record, + ] + return { events, options, props } +} + +describe('media subscribe identifiers and modes', () => { + it.each([ + ['TMDB before all fallback identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: 10 }, 'tmdb:10'], + ['Douban before Bangumi and generic identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: undefined }, 'douban:20'], + ['Bangumi before a generic identifier', { bangumi_id: '30', douban_id: undefined, tmdb_id: undefined }, 'bangumi:30'], + [ + 'generic identifiers when provider ids are absent', + { bangumi_id: undefined, douban_id: undefined, media_id: 'abc', mediaid_prefix: 'custom', tmdb_id: undefined }, + 'custom:abc', + ], + ])('uses %s', (_case, overrides, expected) => { + expect(getMediaSubscribeId(createSubscribeMovie(overrides))).toBe(expected) + }) + + it.each([ + [{ best_version: false, best_version_full: true }, 'normal'], + [{ best_version: 0, best_version_full: 1 }, 'normal'], + [{ best_version: '0', best_version_full: '1' }, 'normal'], + [{ best_version: true, best_version_full: false }, 'best_version'], + [{ best_version: 1, best_version_full: 0 }, 'best_version'], + [{ best_version: '1', best_version_full: '1' }, 'best_version_full'], + ] as const)('normalizes compatible mode flags %#', (subscribe, expected) => { + expect(getSubscribeMode(subscribe)).toBe(expected) + }) +}) + +describe('useMediaSubscribe entry flows', () => { + beforeEach(() => { + mocks.confirm.mockResolvedValue(true) + mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() }) + }) + + it('creates a normal movie subscription and synchronizes public state', async () => { + const media = createSubscribeMovie({ title: '普通电影', tmdb_id: 101, year: '2025' }) + const created = vi.fn() + server.use( + createSubscribeHandler({ data: { id: 501 }, success: true }, 200, created), + defaultSubscribeConfigHandler('电影', { show_edit_dialog: false }), + ) + await renderSubscribeHarness({ media }) + + await fireEvent.click(screen.getByRole('button', { name: 'primary' })) + + await waitFor(() => expect(created).toHaveBeenCalledOnce()) + await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce()) + expect(created).toHaveBeenCalledWith({ + bangumiid: undefined, + doubanid: undefined, + episode_group: '', + mediaid: '', + name: '普通电影', + season: null, + tmdbid: 101, + type: '电影', + year: '2025', + }) + expect(screen.getByTestId('subscribed')).toHaveTextContent('true') + expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', true) + expect(mocks.toastSuccess).toHaveBeenCalled() + expect(mocks.startProgress).toHaveBeenCalledOnce() + expect(mocks.doneProgress).toHaveBeenCalledOnce() + }) + + it('opens the mode chooser for an existing movie and creates the selected mode', async () => { + const media = createSubscribeMovie({ title: '已入库电影', tmdb_id: 102 }) + const created = vi.fn() + server.use( + createSubscribeHandler({ data: { id: 502 }, success: true }, 200, created), + defaultSubscribeConfigHandler('电影', { show_edit_dialog: false }), + ) + await renderSubscribeHarness({ isExists: true, media }) + + await fireEvent.click(screen.getByRole('button', { name: 'primary' })) + const modeDialog = getDialogCall() + expect(modeDialog.props).toMatchObject({ modes: ['normal', 'best_version'], type: '电影' }) + + modeDialog.events.choose('best_version') + await waitFor(() => expect(created).toHaveBeenCalledOnce()) + await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce()) + expect(created.mock.calls[0][0]).toMatchObject({ best_version: 1, best_version_full: 0, season: null }) + }) + + it('opens the TV season chooser with current state and the default mode', async () => { + const media = createSubscribeTv({ season: 2, title: '季选择剧集', tmdb_id: 103 }) + server.use(defaultSubscribeConfigHandler('电视剧', { best_version: '1', best_version_full: '1' })) + await renderSubscribeHarness({ + media, + modes: { 1: 'normal', 2: 'best_version' }, + subscribedSeasons: [1, 2], + }) + + await fireEvent.click(screen.getByRole('button', { name: 'primary' })) + + await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce()) + const dialog = getDialogCall() + expect(dialog.props).toMatchObject({ + defaultSubscribeMode: 'best_version_full', + selectedSeason: undefined, + subscribedSeasonModes: { 1: 'normal', 2: 'best_version' }, + subscribedSeasons: [1, 2], + }) + expect(dialog.options).toEqual({ closeOn: ['close', 'subscribe'] }) + }) + + it('opens the season chooser from an unsubscribed TV season entry', async () => { + const media = createSubscribeTv({ season: 2, title: '单季入口剧集', tmdb_id: 1031 }) + server.use(defaultSubscribeConfigHandler('电视剧', { best_version: 0 })) + await renderSubscribeHarness({ actionSeason: 2, media }) + + await fireEvent.click(screen.getByRole('button', { name: 'season' })) + + await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce()) + const dialog = getDialogCall() + expect(dialog.props).toMatchObject({ + initialEpisodeGroup: 'episode-group-entry', + selectedSeason: 2, + }) + }) + + it('cancels a subscribed movie from the primary entry', async () => { + const deleted = vi.fn() + server.use(deleteSubscribeByMediaHandler('tmdb:1032', { success: true }, 200, url => deleted(url))) + await renderSubscribeHarness({ + isSubscribed: true, + media: createSubscribeMovie({ title: '主入口取消电影', tmdb_id: 1032 }), + }) + + await fireEvent.click(screen.getByRole('button', { name: 'primary' })) + + await waitFor(() => expect(deleted).toHaveBeenCalledOnce()) + expect((deleted.mock.calls[0][0] as URL).searchParams.has('season')).toBe(false) + expect(screen.getByTestId('subscribed')).toHaveTextContent('false') + expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', false) + }) + + it('cancels a subscribed TV season only after confirmation', async () => { + const media = createSubscribeTv({ season: 2, title: '取消季剧集', tmdb_id: 104 }) + const deleted = vi.fn() + server.use(deleteSubscribeByMediaHandler('tmdb:104', { success: true }, 200, url => deleted(url))) + await renderSubscribeHarness({ + isSubscribed: true, + media, + modes: { 1: 'normal', 2: 'best_version' }, + seasonsMap: { 1: true, 2: true }, + subscribedSeasons: [1, 2], + useSeasonMap: true, + }) + mocks.confirm.mockResolvedValueOnce(false) + + await fireEvent.click(screen.getByRole('button', { name: 'season' })) + expect(deleted).not.toHaveBeenCalled() + + await fireEvent.click(screen.getByRole('button', { name: 'season' })) + await waitFor(() => expect(deleted).toHaveBeenCalledOnce()) + expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2') + expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false') + expect(screen.getByTestId('modes')).not.toHaveTextContent('"2"') + expect(screen.getByTestId('subscribed')).toHaveTextContent('true') + expect(mocks.cacheStatus).toHaveBeenCalledWith('status:2', false) + }) + + it.each([ + { + label: 'Douban', + media: createSubscribeTv({ douban_id: 'db-1', tmdb_id: undefined }), + mediaId: 'douban:db-1', + record: createSubscribe({ doubanid: 'db-1', season: 2, tmdbid: 0, type: '电视剧' }), + }, + { + label: 'Bangumi', + media: createSubscribeTv({ bangumi_id: '42', tmdb_id: undefined }), + mediaId: 'bangumi:42', + record: createSubscribe({ bangumiid: 42 as unknown as string, season: 2, tmdbid: 0, type: '电视剧' }), + }, + { + label: 'generic provider', + media: createSubscribeTv({ media_id: 'series-1', mediaid_prefix: 'custom', tmdb_id: undefined }), + mediaId: 'custom:series-1', + record: createSubscribe({ mediaid: 'custom:series-1', season: 2, tmdbid: 0, type: '电视剧' }), + }, + ])('queries $label subscriptions through the media endpoint', async ({ media, mediaId, record }) => { + const queried = vi.fn() + server.use(querySubscribeByMediaHandler(mediaId, record, 200, url => queried(url))) + await renderSubscribeHarness({ actionSeason: 2, media }) + + await fireEvent.click(screen.getByRole('button', { name: 'check' })) + + await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('subscribed')) + expect(queried).toHaveBeenCalledOnce() + expect((queried.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2') + }) + + it('cancels a non-TMDB season through the media endpoint', async () => { + const media = createSubscribeTv({ douban_id: 'db-delete', tmdb_id: undefined }) + const deleted = vi.fn() + server.use(deleteSubscribeByMediaHandler('douban:db-delete', { success: true }, 200, url => deleted(url))) + await renderSubscribeHarness({ + actionSeason: 2, + isSubscribed: true, + media, + seasonsMap: { 2: true }, + useSeasonMap: true, + }) + + await fireEvent.click(screen.getByRole('button', { name: 'remove' })) + + await waitFor(() => expect(deleted).toHaveBeenCalledOnce()) + expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2') + expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false') + }) + + it('aligns visible seasons while preserving hidden subscriptions', async () => { + const media = createSubscribeTv({ title: '多季剧集', tmdb_id: 105 }) + const deleted = vi.fn() + const queried = vi.fn() + const updated = vi.fn() + const created = vi.fn() + server.use( + deleteSubscribeByMediaHandler('tmdb:105', { success: true }, 200, url => deleted(url)), + querySubscribeByMediaHandler( + 'tmdb:105', + createSubscribe({ id: 605, season: 2, tmdbid: 105, type: '电视剧' }), + 200, + url => queried(url), + ), + updateSubscribeHandler({ success: true }, 200, updated), + createSubscribeHandler({ data: { id: 606 }, success: true }, 200, created), + ) + await renderSubscribeHarness({ + isSubscribed: true, + media, + modes: { 1: 'normal', 2: 'normal', 4: 'best_version' }, + multi: { + modes: { 2: 'best_version', 3: 'best_version_full' }, + seasons: [{ season_number: 2 }, { season_number: 3 }], + visible: [1, 2, 3], + }, + subscribedSeasons: [1, 2, 4], + }) + + await fireEvent.click(screen.getByRole('button', { name: 'align-seasons' })) + + await waitFor(() => expect(deleted).toHaveBeenCalledOnce()) + await waitFor(() => expect(updated).toHaveBeenCalledOnce()) + await waitFor(() => expect(created).toHaveBeenCalledOnce()) + expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('1') + expect((queried.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2') + expect(updated.mock.calls[0][0]).toMatchObject({ best_version: 1, best_version_full: 0, id: 605, season: 2 }) + expect(created.mock.calls[0][0]).toMatchObject({ + best_version: 1, + best_version_full: 1, + episode_group: 'episode-group-1', + season: 3, + }) + await waitFor(() => expect(screen.getByTestId('seasons')).toHaveTextContent('[2,3,4]')) + expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"') + expect(screen.getByTestId('modes')).toHaveTextContent('"3":"best_version_full"') + expect(screen.getByTestId('modes')).toHaveTextContent('"4":"best_version"') + }) + + it('synchronizes the created season after edit save and remove events', async () => { + const media = createSubscribeTv({ season: 2, title: '编辑后同步', tmdb_id: 106 }) + server.use( + createSubscribeHandler({ data: { id: 701 }, success: true }), + defaultSubscribeConfigHandler('电视剧', { show_edit_dialog: true }), + ) + await renderSubscribeHarness({ + isSubscribed: true, + media, + modes: { 1: 'normal' }, + subscribedSeasons: [1], + }) + + await fireEvent.click(screen.getByRole('button', { name: 'add-best-full' })) + await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce()) + expect(screen.getByTestId('seasons')).toHaveTextContent('[1,2]') + const editDialog = getDialogCall() + expect(editDialog.props).toEqual({ subid: 701 }) + + editDialog.events.save(createSubscribe({ best_version: 1, best_version_full: 0, id: 701, season: 2, type: '电视剧' })) + await waitFor(() => expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"')) + + editDialog.events.remove() + await waitFor(() => expect(screen.getByTestId('seasons')).toHaveTextContent('[1]')) + expect(screen.getByTestId('subscribed')).toHaveTextContent('true') + expect(screen.getByTestId('modes')).not.toHaveTextContent('"2"') + expect(mocks.cacheStatus).toHaveBeenLastCalledWith('status:2', false) + expect(mocks.onEditRemove).toHaveBeenCalledOnce() + }) + + it.each([ + ['business failure', 200, { message: 'duplicate', success: false }], + ['HTTP failure', 500, { message: 'server down', success: false }], + ])('keeps state unchanged when create returns a %s', async (_case, status, response) => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + server.use(createSubscribeHandler(response, status)) + await renderSubscribeHarness({ media: createSubscribeMovie({ tmdb_id: 107 }) }) + + await fireEvent.click(screen.getByRole('button', { name: 'add-normal' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalled()) + expect(screen.getByTestId('subscribed')).toHaveTextContent('false') + expect(mocks.cacheStatus).not.toHaveBeenCalled() + expect(mocks.openSharedDialog).not.toHaveBeenCalled() + expect(mocks.doneProgress).toHaveBeenCalledOnce() + consoleError.mockRestore() + }) + + it.each([ + ['business failure', 200, { message: 'delete rejected', success: false }], + ['HTTP failure', 500, { message: 'server down', success: false }], + ])('keeps subscription state when removal returns a %s', async (_case, status, response) => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const deleted = vi.fn() + server.use(deleteSubscribeByMediaHandler('tmdb:108', response, status, url => deleted(url))) + await renderSubscribeHarness({ + actionSeason: 2, + isSubscribed: true, + media: createSubscribeTv({ season: 2, tmdb_id: 108 }), + modes: { 1: 'normal', 2: 'best_version' }, + seasonsMap: { 1: true, 2: true }, + subscribedSeasons: [1, 2], + useSeasonMap: true, + }) + + await fireEvent.click(screen.getByRole('button', { name: 'remove' })) + + await waitFor(() => expect(deleted).toHaveBeenCalledOnce()) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalled()) + expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2') + expect(screen.getByTestId('subscribed')).toHaveTextContent('true') + expect(screen.getByTestId('seasons')).toHaveTextContent('[1,2]') + expect(screen.getByTestId('season-map')).toHaveTextContent('"2":true') + expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"') + expect(mocks.cacheStatus).not.toHaveBeenCalled() + expect(mocks.doneProgress).toHaveBeenCalledOnce() + consoleError.mockRestore() + }) + + it.each([ + ['business failure', 200, { message: 'update rejected', success: false }], + ['HTTP failure', 500, { message: 'server down', success: false }], + ])('keeps the subscribed mode when an update returns a %s', async (_case, status, response) => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const media = createSubscribeTv({ title: '模式更新失败剧集', tmdb_id: 110 }) + const updated = vi.fn() + server.use( + querySubscribeByMediaHandler( + 'tmdb:110', + createSubscribe({ id: 710, season: 2, tmdbid: 110, type: '电视剧' }), + ), + updateSubscribeHandler(response, status, updated), + ) + await renderSubscribeHarness({ + isSubscribed: true, + media, + modes: { 2: 'normal' }, + multi: { + modes: { 2: 'best_version' }, + seasons: [{ season_number: 2 }], + visible: [2], + }, + subscribedSeasons: [2], + }) + + await fireEvent.click(screen.getByRole('button', { name: 'align-seasons' })) + + await waitFor(() => expect(updated).toHaveBeenCalledOnce()) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + expect(screen.getByTestId('subscribed')).toHaveTextContent('true') + expect(screen.getByTestId('seasons')).toHaveTextContent('[2]') + expect(screen.getByTestId('modes')).toHaveTextContent('"2":"normal"') + expect(mocks.cacheStatus).not.toHaveBeenCalled() + expect(mocks.doneProgress).toHaveBeenCalledOnce() + consoleError.mockRestore() + }) + + it('maps a 404 query to missing and propagates other HTTP errors', async () => { + const media = createSubscribeMovie({ tmdb_id: 109 }) + server.use(querySubscribeByMediaHandler('tmdb:109', {}, 404)) + await renderSubscribeHarness({ media }) + + await fireEvent.click(screen.getByRole('button', { name: 'check' })) + await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('missing')) + + server.use(querySubscribeByMediaHandler('tmdb:109', {}, 500)) + await fireEvent.click(screen.getByRole('button', { name: 'check' })) + await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('error')) + }) + + it('does nothing when the current media is unavailable', async () => { + await renderSubscribeHarness() + + await fireEvent.click(screen.getByRole('button', { name: 'primary' })) + await fireEvent.click(screen.getByRole('button', { name: 'add-normal' })) + await fireEvent.click(screen.getByRole('button', { name: 'open-season' })) + + expect(mocks.openSharedDialog).not.toHaveBeenCalled() + expect(mocks.startProgress).not.toHaveBeenCalled() + }) +}) diff --git a/src/composables/useMediaSubscribe.ts b/src/composables/useMediaSubscribe.ts index c1f2bbf7..853f6122 100644 --- a/src/composables/useMediaSubscribe.ts +++ b/src/composables/useMediaSubscribe.ts @@ -92,6 +92,16 @@ function getModeName(t: ReturnType['t'], mode: SubscribeMode) { return t('dialog.subscribeMode.bestVersionFull') } +// 从变更请求异常中提取可展示消息,并为非标准错误提供稳定兜底。 +function getRequestErrorMessage(error: unknown, fallback: string) { + if (typeof error === 'object' && error !== null) { + const responseMessage = (error as { response?: { data?: { message?: unknown } } }).response?.data?.message + if (typeof responseMessage === 'string' && responseMessage) return responseMessage + } + if (error instanceof Error && error.message) return error.message + return fallback +} + // 封装媒体卡片与详情页共用的订阅交互。 export function useMediaSubscribe(options: UseMediaSubscribeOptions) { const { t } = useI18n() @@ -155,17 +165,19 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { } // 打开已创建订阅的编辑弹窗。 - function openSubscribeEditDialog(subid: number) { + function openSubscribeEditDialog(subid: number, season: number | null, mode: SubscribeMode) { openSharedDialog( SubscribeEditDialog, { subid }, { + save: (subscribe?: Subscribe) => { + const savedSeason = currentMedia()?.type === '电影' ? null : (subscribe?.season ?? season) + if (savedSeason !== season) updateSubscribeStatus(season, false) + updateSubscribeStatus(savedSeason, true, subscribe ? getSubscribeMode(subscribe) : mode) + }, remove: () => { - if (options.onEditRemove) { - options.onEditRemove() - } else if (options.isSubscribed) { - options.isSubscribed.value = false - } + updateSubscribeStatus(season, false) + options.onEditRemove?.() }, }, { closeOn: ['close', 'save', 'remove'] }, @@ -270,16 +282,33 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { episode_group: episodeGroup.value, }) - if (result.success) updateSubscribeStatus(media.type === '电影' ? null : season, true, getSubscribeMode(payload)) + const subscribeSeason = media.type === '电影' ? null : season + const subscribeMode = getSubscribeMode(payload) + if (result.success) updateSubscribeStatus(subscribeSeason, true, subscribeMode) - showSubscribeAddToast(result.success, media.title ?? '', season, result.message, payload.best_version ?? 0) + showSubscribeAddToast( + result.success, + media.title ?? '', + season, + result.message ?? t('subscribe.requestFailed'), + payload.best_version ?? 0, + ) if (result.success && (addOptions.openEditDialog ?? true)) { const subscribeConfig = await queryDefaultSubscribeConfig() - if (subscribeConfig?.show_edit_dialog) openSubscribeEditDialog(result.data.id) + if (subscribeConfig?.show_edit_dialog && result.data?.id) { + openSubscribeEditDialog(result.data.id, subscribeSeason, subscribeMode) + } } } catch (error) { console.error(error) + showSubscribeAddToast( + false, + media.title ?? '', + season, + getRequestErrorMessage(error, t('subscribe.requestFailed')), + payload.best_version ?? 0, + ) } finally { doneNProgress() } @@ -297,6 +326,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { const media = currentMedia() if (!media) return + let title = media.title ?? '' + if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}` startNProgress() try { @@ -305,17 +336,20 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { season: media.type === '电影' ? null : season, }, }) - let title = media.title ?? '' - if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}` if (result.success) { updateSubscribeStatus(media.type === '电影' ? null : season, false) $toast.success(`${title} ${t('subscribe.cancelSuccess')}`) } else { - $toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message })}`) + $toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message ?? t('subscribe.requestFailed') })}`) } } catch (error) { console.error(error) + $toast.error( + `${title} ${t('subscribe.cancelFailed', { + message: getRequestErrorMessage(error, t('subscribe.requestFailed')), + })}`, + ) } finally { doneNProgress() } @@ -361,6 +395,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { async function updateSubscribeMode(season: number, mode: SubscribeMode) { const media = currentMedia() if (!media) return + const title = `${media.title ?? ''} ${formatSeason(season.toString())}` startNProgress() try { @@ -375,16 +410,26 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { ...subscribe, ...payload, }) - const title = `${media.title ?? ''} ${formatSeason(season.toString())}` if (result.success) { updateSubscribeStatus(season, true, mode) $toast.success(`${title} ${t('subscribe.modeUpdateSuccess', { mode: getModeName(t, mode) })}`) } else { - $toast.error(`${title} ${t('subscribe.addFailed', { name: getModeName(t, mode), message: result.message })}`) + $toast.error( + `${title} ${t('subscribe.addFailed', { + name: getModeName(t, mode), + message: result.message ?? t('subscribe.requestFailed'), + })}`, + ) } } catch (error) { console.error(error) + $toast.error( + `${title} ${t('subscribe.addFailed', { + name: getModeName(t, mode), + message: getRequestErrorMessage(error, t('subscribe.requestFailed')), + })}`, + ) } finally { doneNProgress() } diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 50c31827..f58468ac 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -1125,6 +1125,7 @@ export default { cancelSuccess: 'Subscription cancelled!', cancelFailed: 'Failed to cancel subscription: {message}!', notFound: 'Subscription not found!', + requestFailed: 'Request failed. Please try again later.', filterSubscriptions: 'Filter Subscriptions', name: 'Name', searchShares: 'Search Subscription Shares', @@ -3168,6 +3169,10 @@ export default { cancelSubscribe: 'Cancel Subscription', save: 'Save', cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?', + updateSuccess: '{name} updated successfully!', + updateFailed: 'Failed to update {name}: {message}!', + defaultSaveSuccess: 'Default {type} subscription rules saved successfully.', + defaultSaveFailed: 'Failed to save default {type} subscription rules: {message}!', }, subscribeFiles: { title: 'Subscription Files', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 8ac6cca9..4cea99de 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -1119,6 +1119,7 @@ export default { cancelSuccess: '已取消订阅!', cancelFailed: '取消订阅失败:{message}!', notFound: '订阅不存在!', + requestFailed: '请求失败,请稍后重试', filterSubscriptions: '筛选订阅', name: '名称', searchShares: '搜索订阅分享', @@ -3117,6 +3118,10 @@ export default { cancelSubscribe: '取消订阅', save: '保存', cancelSubscribeConfirm: '是否确认取消订阅?', + updateSuccess: '{name} 更新成功!', + updateFailed: '{name} 更新失败:{message}!', + defaultSaveSuccess: '{type}订阅默认规则保存成功', + defaultSaveFailed: '{type}订阅默认规则保存失败:{message}!', }, subscribeFiles: { title: '订阅文件', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 73850a8b..64db4f42 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -1119,6 +1119,7 @@ export default { cancelSuccess: '已取消訂閱!', cancelFailed: '取消訂閱失敗:{message}!', notFound: '訂閱不存在!', + requestFailed: '請求失敗,請稍後重試', filterSubscriptions: '篩選訂閱', name: '名稱', searchShares: '搜索訂閱分享', @@ -3116,6 +3117,10 @@ export default { cancelSubscribe: '取消訂閱', save: '儲存', cancelSubscribeConfirm: '是否確認取消訂閱?', + updateSuccess: '{name} 更新成功!', + updateFailed: '{name} 更新失敗:{message}!', + defaultSaveSuccess: '{type}訂閱默認規則儲存成功', + defaultSaveFailed: '{type}訂閱默認規則儲存失敗:{message}!', }, subscribeFiles: { title: '訂閱文件', diff --git a/src/pages/__tests__/recommend.spec.ts b/src/pages/__tests__/recommend.spec.ts index e548b82f..535500e8 100644 --- a/src/pages/__tests__/recommend.spec.ts +++ b/src/pages/__tests__/recommend.spec.ts @@ -49,7 +49,13 @@ const MediaCardSlideViewStub = defineComponent({ }) interface SharedDialogEvents { + close: () => void save: (payload?: { enabled?: Record }) => Promise + 'update:modelValue': (value: boolean) => void +} + +interface SharedDialogProps { + valueGetter: (item: { title: string }) => string } async function renderRecommend(options: { superUser?: boolean; discovery?: boolean } = {}) { @@ -179,6 +185,36 @@ describe('recommend page', () => { expect(mocks.closeDialog).toHaveBeenCalledOnce() }) + it('releases the shared settings controller through both close contracts', async () => { + const sourcesRequested = vi.fn() + const user = userEvent.setup() + localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true })) + server.use(recommendSourcesHandler([], 200, sourcesRequested)) + await renderRecommend() + await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce()) + await waitFor(() => expect(document.querySelector('.compact-fab')).not.toBeNull()) + const settingsButton = document.querySelector('.compact-fab') as HTMLButtonElement + + await user.click(settingsButton) + const dialogProps = mocks.openSharedDialog.mock.calls[0][1] as SharedDialogProps + const firstDialogEvents = mocks.openSharedDialog.mock.calls[0][2] as SharedDialogEvents + expect(dialogProps.valueGetter({ title: '流行趋势' })).toBe('流行趋势') + + firstDialogEvents.close() + await user.click(settingsButton) + expect(mocks.closeDialog).not.toHaveBeenCalled() + + const secondDialogEvents = mocks.openSharedDialog.mock.calls[1][2] as SharedDialogEvents + secondDialogEvents['update:modelValue'](true) + await user.click(settingsButton) + expect(mocks.closeDialog).toHaveBeenCalledOnce() + + const thirdDialogEvents = mocks.openSharedDialog.mock.calls[2][2] as SharedDialogEvents + thirdDialogEvents['update:modelValue'](false) + await user.click(settingsButton) + expect(mocks.closeDialog).toHaveBeenCalledOnce() + }) + it.each([ { discovery: false, superUser: false, visible: false }, { discovery: false, superUser: true, visible: true }, diff --git a/tests/support/factories/subscribe.ts b/tests/support/factories/subscribe.ts new file mode 100644 index 00000000..dc9f7927 --- /dev/null +++ b/tests/support/factories/subscribe.ts @@ -0,0 +1,101 @@ +import type { + DownloaderConf, + FilterRuleGroup, + MediaInfo, + Site, + Subscribe, + TransferDirectoryConf, +} from '@/api/types' +import { createMediaInfo } from './media' + +let subscribeSeed = 1000 +let siteSeed = 100 + +/** 构造满足前端订阅契约的最小记录。 */ +export function createSubscribe(overrides: Partial = {}): Subscribe { + subscribeSeed += 1 + return { + best_version: 0, + best_version_full: 0, + current_priority: 0, + date: '2026-07-16', + downloader: '', + episode_group: '', + id: subscribeSeed, + last_update: '2026-07-16 12:00:00', + name: `测试订阅 ${subscribeSeed}`, + show_edit_dialog: false, + sites: [], + state: 'R', + tmdbid: subscribeSeed, + type: '电影', + username: 'tester', + year: '2026', + ...overrides, + } +} + +/** 构造电影媒体信息。 */ +export function createSubscribeMovie(overrides: Partial = {}): MediaInfo { + return createMediaInfo({ type: '电影', ...overrides }) +} + +/** 构造电视剧媒体信息。 */ +export function createSubscribeTv(overrides: Partial = {}): MediaInfo { + return createMediaInfo({ + season: 1, + season_info: [ + { episode_count: 12, name: '第 1 季', season_number: 1 }, + { episode_count: 10, name: '第 2 季', season_number: 2 }, + ], + type: '电视剧', + ...overrides, + }) +} + +/** 构造订阅站点选项。 */ +export function createSubscribeSite(overrides: Partial = {}): Site { + siteSeed += 1 + return { + domain: `site-${siteSeed}.example.com`, + downloader: '', + id: siteSeed, + is_active: true, + name: `测试站点 ${siteSeed}`, + url: `https://site-${siteSeed}.example.com`, + ...overrides, + } +} + +/** 构造下载器选项。 */ +export function createSubscribeDownloader(overrides: Partial = {}): DownloaderConf { + return { + config: {}, + default: false, + enabled: true, + name: '测试下载器', + type: 'qbittorrent', + ...overrides, + } +} + +/** 构造下载目录配置。 */ +export function createSubscribeDirectory(overrides: Partial = {}): TransferDirectoryConf { + return { + download_path: '/downloads', + name: '测试目录', + priority: 1, + storage: 'local', + transfer_type: 'link', + ...overrides, + } +} + +/** 构造订阅过滤规则组。 */ +export function createSubscribeRuleGroup(overrides: Partial = {}): FilterRuleGroup { + return { + name: '默认规则组', + rule_string: 'priority=1', + ...overrides, + } +} diff --git a/tests/support/msw/handlers/subscribe.ts b/tests/support/msw/handlers/subscribe.ts new file mode 100644 index 00000000..fe877709 --- /dev/null +++ b/tests/support/msw/handlers/subscribe.ts @@ -0,0 +1,181 @@ +import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types' +import { HttpResponse, http, type JsonBodyType, type RequestHandler } from 'msw' + +const API_BASE_URL = 'http://localhost/api/v1/' + +export type SubscribeMediaType = '电影' | '电视剧' + +export interface SubscribeMutationResponse { + success: boolean + data?: Record + message?: string +} + +export const subscribeApiUrls = { + create: new URL('subscribe/', API_BASE_URL).href, + defaultConfig: (type: SubscribeMediaType, writable = false) => + new URL( + `system/setting/${writable ? '' : 'public/'}${type === '电影' ? 'DefaultMovieSubscribeConfig' : 'DefaultTvSubscribeConfig'}`, + API_BASE_URL, + ).href, + deleteById: (id: number) => new URL(`subscribe/${id}`, API_BASE_URL).href, + deleteByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href, + details: (id: number) => new URL(`subscribe/${id}`, API_BASE_URL).href, + directories: new URL('system/setting/public/Directories', API_BASE_URL).href, + downloaders: new URL('download/clients', API_BASE_URL).href, + episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href, + filterRuleGroups: new URL('system/setting/UserFilterRuleGroups', API_BASE_URL).href, + queryByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href, + sites: new URL('site/rss', API_BASE_URL).href, + update: new URL('subscribe/', API_BASE_URL).href, +} + +function jsonResponse(body: JsonBodyType, status: number) { + return HttpResponse.json(body, { status }) +} + +export function createSubscribeHandler( + response: SubscribeMutationResponse = { data: { id: 1 }, success: true }, + status = 200, + onCreate: (payload: Record) => void = () => {}, +) { + return http.post(subscribeApiUrls.create, async ({ request }) => { + const payload = (await request.json()) as Record + onCreate(payload) + return jsonResponse(response, status) + }) +} + +export function updateSubscribeHandler( + response: SubscribeMutationResponse = { success: true }, + status = 200, + onUpdate: (payload: Record) => void = () => {}, +) { + return http.put(subscribeApiUrls.update, async ({ request }) => { + const payload = (await request.json()) as Record + onUpdate(payload) + return jsonResponse(response, status) + }) +} + +export function querySubscribeByMediaHandler( + mediaId: string, + subscribe: Partial, + status = 200, + onRequest: (url: URL) => void = () => {}, +) { + return http.get(subscribeApiUrls.queryByMedia(mediaId), ({ request }) => { + onRequest(new URL(request.url)) + return jsonResponse(subscribe as JsonBodyType, status) + }) +} + +export function deleteSubscribeByMediaHandler( + mediaId: string, + response: SubscribeMutationResponse = { success: true }, + status = 200, + onRequest: (url: URL) => void = () => {}, +) { + return http.delete(subscribeApiUrls.deleteByMedia(mediaId), ({ request }) => { + onRequest(new URL(request.url)) + return jsonResponse(response, status) + }) +} + +export function subscribeDetailsHandler(id: number, subscribe: Subscribe, status = 200, onRequest: () => void = () => {}) { + return http.get(subscribeApiUrls.details(id), () => { + onRequest() + return jsonResponse(subscribe as unknown as JsonBodyType, status) + }) +} + +export function deleteSubscribeByIdHandler( + id: number, + response: SubscribeMutationResponse = { success: true }, + status = 200, + onRequest: () => void = () => {}, +) { + return http.delete(subscribeApiUrls.deleteById(id), () => { + onRequest() + return jsonResponse(response, status) + }) +} + +export function defaultSubscribeConfigHandler( + type: SubscribeMediaType, + config: Partial, + status = 200, + onRequest: () => void = () => {}, +) { + return http.get(subscribeApiUrls.defaultConfig(type), () => { + onRequest() + return jsonResponse({ data: { value: config }, success: status < 400 }, status) + }) +} + +export function saveDefaultSubscribeConfigHandler( + type: SubscribeMediaType, + response: SubscribeMutationResponse = { success: true }, + status = 200, + onSave: (payload: Record) => void = () => {}, +) { + return http.post(subscribeApiUrls.defaultConfig(type, true), async ({ request }) => { + const payload = (await request.json()) as Record + onSave(payload) + return jsonResponse(response, status) + }) +} + +export interface SubscribeDialogOptions { + directories?: TransferDirectoryConf[] + downloaders?: DownloaderConf[] + episodeGroups?: Record[] + filterRuleGroups?: FilterRuleGroup[] + onDirectories?: () => void + onDownloaders?: () => void + onEpisodeGroups?: () => void + onFilterRuleGroups?: () => void + onSites?: () => void + sites?: Site[] + tmdbId?: number +} + +/** 为编辑弹窗提供彼此独立、可按测试覆盖的选项接口。 */ +export function subscribeDialogOptionHandlers(options: SubscribeDialogOptions = {}): RequestHandler[] { + const { + directories = [], + downloaders = [], + episodeGroups = [], + filterRuleGroups = [], + onDirectories = () => {}, + onDownloaders = () => {}, + onEpisodeGroups = () => {}, + onFilterRuleGroups = () => {}, + onSites = () => {}, + sites = [], + tmdbId = 1, + } = options + + return [ + http.get(subscribeApiUrls.sites, () => { + onSites() + return jsonResponse(sites as unknown as JsonBodyType, 200) + }), + http.get(subscribeApiUrls.downloaders, () => { + onDownloaders() + return jsonResponse(downloaders as unknown as JsonBodyType, 200) + }), + http.get(subscribeApiUrls.directories, () => { + onDirectories() + return jsonResponse({ data: { value: directories }, success: true }, 200) + }), + http.get(subscribeApiUrls.filterRuleGroups, () => { + onFilterRuleGroups() + return jsonResponse({ data: { value: filterRuleGroups }, success: true }, 200) + }), + http.get(subscribeApiUrls.episodeGroups(tmdbId), () => { + onEpisodeGroups() + return jsonResponse(episodeGroups as unknown as JsonBodyType, 200) + }), + ] +} diff --git a/vite.config.ts b/vite.config.ts index facca40e..5e0582a7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -276,15 +276,59 @@ export default defineConfig(({ mode }) => ({ 'src/stores/auth.ts', 'src/pages/recommend.vue', 'src/views/dashboard/MediaRecommend.vue', + 'src/composables/useMediaSubscribe.ts', + 'src/components/dialog/SubscribeEditDialog.vue', ], provider: 'v8', reporter: ['text', 'json-summary', 'html'], reportsDirectory: 'coverage', thresholds: { - branches: 75, - functions: 80, - lines: 80, - statements: 80, + branches: 80, + functions: 85, + lines: 85, + statements: 85, + 'src/components/dialog/SubscribeEditDialog.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/composables/useMediaSubscribe.ts': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/pages/recommend.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/stores/auth.ts': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/utils/permission.ts': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/utils/recommendSources.ts': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/views/dashboard/MediaRecommend.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, }, }, },