mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 08:06:43 +08:00
test(subscribe): cover subscription management flows (#530)
This commit is contained in:
+1
-1
@@ -78,7 +78,7 @@ tests/
|
|||||||
|
|
||||||
Vitest 只收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。
|
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
|
## 命令与 CI
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constant
|
|||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
import { formatSeason } from '@/@core/utils/formatters'
|
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
|
// i18n
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -104,6 +115,12 @@ function getSubscribeDisplayName() {
|
|||||||
return `${name} ${formatSeason(season.toString())}`
|
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 }) {
|
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||||
return {
|
return {
|
||||||
@@ -162,18 +179,30 @@ const filterRuleGroupOptions = computed(() => {
|
|||||||
|
|
||||||
// 调用API修改订阅
|
// 调用API修改订阅
|
||||||
async function updateSubscribeInfo() {
|
async function updateSubscribeInfo() {
|
||||||
|
const displayName = getSubscribeDisplayName()
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value)
|
const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value)
|
||||||
// 提示
|
// 提示
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(`${getSubscribeDisplayName()} 更新成功!`)
|
$toast.success(t('dialog.subscribeEdit.updateSuccess', { name: displayName }))
|
||||||
// 通知父组件刷新
|
// 通知父组件刷新
|
||||||
emit('save')
|
emit('save', subscribeForm.value)
|
||||||
} else {
|
} else {
|
||||||
$toast.error(`${getSubscribeDisplayName()} 更新失败:${result.message}!`)
|
$toast.error(
|
||||||
|
t('dialog.subscribeEdit.updateFailed', {
|
||||||
|
name: displayName,
|
||||||
|
message: result.message ?? t('subscribe.requestFailed'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(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() {
|
async function saveDefaultSubscribeConfig() {
|
||||||
if (!canAdmin.value) return
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
|
const typeName = getDefaultSubscribeTypeName()
|
||||||
try {
|
try {
|
||||||
let subscribe_config_url = ''
|
let subscribe_config_url = ''
|
||||||
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)
|
const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)
|
||||||
if (result.success) $toast.success(`${props.type}订阅默认规则保存成功`)
|
if (result.success) {
|
||||||
else $toast.error(`${props.type}订阅默认规则保存失败!`)
|
$toast.success(t('dialog.subscribeEdit.defaultSaveSuccess', { type: typeName }))
|
||||||
|
// 通知父组件刷新
|
||||||
// 通知父组件刷新
|
emit('save', subscribeForm.value)
|
||||||
emit('save')
|
} else {
|
||||||
|
$toast.error(
|
||||||
|
t('dialog.subscribeEdit.defaultSaveFailed', {
|
||||||
|
type: typeName,
|
||||||
|
message: result.message ?? t('subscribe.requestFailed'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(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
|
if (!isConfirmed) return
|
||||||
|
const displayName = getSubscribeDisplayName()
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
|
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(`订阅 ${getSubscribeDisplayName()} 已取消!`)
|
$toast.success(`${displayName} ${t('subscribe.cancelSuccess')}`)
|
||||||
// 通知父组件刷新
|
// 通知父组件刷新
|
||||||
emit('remove')
|
emit('remove')
|
||||||
|
} else {
|
||||||
|
$toast.error(
|
||||||
|
`${displayName} ${t('subscribe.cancelFailed', {
|
||||||
|
message: result.message ?? t('subscribe.requestFailed'),
|
||||||
|
})}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(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(() => {
|
const targetDirectories = computed(() => {
|
||||||
// 去重后的下载目录
|
const paths = downloadDirectories.value
|
||||||
return downloadDirectories.value.map(item => item.download_path)
|
.map(item => item.download_path?.trim())
|
||||||
|
.filter((path): path is string => Boolean(path))
|
||||||
|
return [...new Set(paths)]
|
||||||
})
|
})
|
||||||
|
|
||||||
// 仅电视剧订阅支持全集洗版,电影保持原有洗版逻辑
|
// 仅电视剧订阅支持全集洗版,电影保持原有洗版逻辑
|
||||||
|
|||||||
@@ -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<HTMLButtonElement>('.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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<number, boolean>
|
||||||
|
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<Record<number, boolean>>({ ...(options.seasonsMap ?? {}) })
|
||||||
|
const subscribedSeasons = ref([...(options.subscribedSeasons ?? [])])
|
||||||
|
const subscribedSeasonModes = ref<SeasonSubscribeModes>({ ...(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: `
|
||||||
|
<button type="button" @click="handlePrimary">primary</button>
|
||||||
|
<button type="button" @click="handleSeason">season</button>
|
||||||
|
<button type="button" @click="addNormal">add-normal</button>
|
||||||
|
<button type="button" @click="addBestFull">add-best-full</button>
|
||||||
|
<button type="button" @click="remove">remove</button>
|
||||||
|
<button type="button" @click="check">check</button>
|
||||||
|
<button type="button" @click="openSeason">open-season</button>
|
||||||
|
<button type="button" @click="alignSeasons">align-seasons</button>
|
||||||
|
<output data-testid="subscribed">{{ String(isSubscribed) }}</output>
|
||||||
|
<output data-testid="seasons">{{ JSON.stringify(seasons) }}</output>
|
||||||
|
<output data-testid="season-map">{{ JSON.stringify(seasonsMap) }}</output>
|
||||||
|
<output data-testid="modes">{{ JSON.stringify(modes) }}</output>
|
||||||
|
<output data-testid="check-result">{{ checkResult }}</output>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
Record<string, (...args: any[]) => unknown>,
|
||||||
|
Record<string, unknown>,
|
||||||
|
]
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -92,6 +92,16 @@ function getModeName(t: ReturnType<typeof useI18n>['t'], mode: SubscribeMode) {
|
|||||||
return t('dialog.subscribeMode.bestVersionFull')
|
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) {
|
export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||||
const { t } = useI18n()
|
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(
|
openSharedDialog(
|
||||||
SubscribeEditDialog,
|
SubscribeEditDialog,
|
||||||
{ subid },
|
{ 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: () => {
|
remove: () => {
|
||||||
if (options.onEditRemove) {
|
updateSubscribeStatus(season, false)
|
||||||
options.onEditRemove()
|
options.onEditRemove?.()
|
||||||
} else if (options.isSubscribed) {
|
|
||||||
options.isSubscribed.value = false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ closeOn: ['close', 'save', 'remove'] },
|
{ closeOn: ['close', 'save', 'remove'] },
|
||||||
@@ -270,16 +282,33 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
|||||||
episode_group: episodeGroup.value,
|
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)) {
|
if (result.success && (addOptions.openEditDialog ?? true)) {
|
||||||
const subscribeConfig = await queryDefaultSubscribeConfig()
|
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) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
showSubscribeAddToast(
|
||||||
|
false,
|
||||||
|
media.title ?? '',
|
||||||
|
season,
|
||||||
|
getRequestErrorMessage(error, t('subscribe.requestFailed')),
|
||||||
|
payload.best_version ?? 0,
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
doneNProgress()
|
doneNProgress()
|
||||||
}
|
}
|
||||||
@@ -297,6 +326,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
|||||||
|
|
||||||
const media = currentMedia()
|
const media = currentMedia()
|
||||||
if (!media) return
|
if (!media) return
|
||||||
|
let title = media.title ?? ''
|
||||||
|
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||||
|
|
||||||
startNProgress()
|
startNProgress()
|
||||||
try {
|
try {
|
||||||
@@ -305,17 +336,20 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
|||||||
season: media.type === '电影' ? null : season,
|
season: media.type === '电影' ? null : season,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
let title = media.title ?? ''
|
|
||||||
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
updateSubscribeStatus(media.type === '电影' ? null : season, false)
|
updateSubscribeStatus(media.type === '电影' ? null : season, false)
|
||||||
$toast.success(`${title} ${t('subscribe.cancelSuccess')}`)
|
$toast.success(`${title} ${t('subscribe.cancelSuccess')}`)
|
||||||
} else {
|
} else {
|
||||||
$toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
$toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message ?? t('subscribe.requestFailed') })}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
$toast.error(
|
||||||
|
`${title} ${t('subscribe.cancelFailed', {
|
||||||
|
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
|
||||||
|
})}`,
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
doneNProgress()
|
doneNProgress()
|
||||||
}
|
}
|
||||||
@@ -361,6 +395,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
|||||||
async function updateSubscribeMode(season: number, mode: SubscribeMode) {
|
async function updateSubscribeMode(season: number, mode: SubscribeMode) {
|
||||||
const media = currentMedia()
|
const media = currentMedia()
|
||||||
if (!media) return
|
if (!media) return
|
||||||
|
const title = `${media.title ?? ''} ${formatSeason(season.toString())}`
|
||||||
|
|
||||||
startNProgress()
|
startNProgress()
|
||||||
try {
|
try {
|
||||||
@@ -375,16 +410,26 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
|||||||
...subscribe,
|
...subscribe,
|
||||||
...payload,
|
...payload,
|
||||||
})
|
})
|
||||||
const title = `${media.title ?? ''} ${formatSeason(season.toString())}`
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
updateSubscribeStatus(season, true, mode)
|
updateSubscribeStatus(season, true, mode)
|
||||||
$toast.success(`${title} ${t('subscribe.modeUpdateSuccess', { mode: getModeName(t, mode) })}`)
|
$toast.success(`${title} ${t('subscribe.modeUpdateSuccess', { mode: getModeName(t, mode) })}`)
|
||||||
} else {
|
} 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) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
$toast.error(
|
||||||
|
`${title} ${t('subscribe.addFailed', {
|
||||||
|
name: getModeName(t, mode),
|
||||||
|
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
|
||||||
|
})}`,
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
doneNProgress()
|
doneNProgress()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1125,6 +1125,7 @@ export default {
|
|||||||
cancelSuccess: 'Subscription cancelled!',
|
cancelSuccess: 'Subscription cancelled!',
|
||||||
cancelFailed: 'Failed to cancel subscription: {message}!',
|
cancelFailed: 'Failed to cancel subscription: {message}!',
|
||||||
notFound: 'Subscription not found!',
|
notFound: 'Subscription not found!',
|
||||||
|
requestFailed: 'Request failed. Please try again later.',
|
||||||
filterSubscriptions: 'Filter Subscriptions',
|
filterSubscriptions: 'Filter Subscriptions',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
searchShares: 'Search Subscription Shares',
|
searchShares: 'Search Subscription Shares',
|
||||||
@@ -3168,6 +3169,10 @@ export default {
|
|||||||
cancelSubscribe: 'Cancel Subscription',
|
cancelSubscribe: 'Cancel Subscription',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?',
|
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: {
|
subscribeFiles: {
|
||||||
title: 'Subscription Files',
|
title: 'Subscription Files',
|
||||||
|
|||||||
@@ -1119,6 +1119,7 @@ export default {
|
|||||||
cancelSuccess: '已取消订阅!',
|
cancelSuccess: '已取消订阅!',
|
||||||
cancelFailed: '取消订阅失败:{message}!',
|
cancelFailed: '取消订阅失败:{message}!',
|
||||||
notFound: '订阅不存在!',
|
notFound: '订阅不存在!',
|
||||||
|
requestFailed: '请求失败,请稍后重试',
|
||||||
filterSubscriptions: '筛选订阅',
|
filterSubscriptions: '筛选订阅',
|
||||||
name: '名称',
|
name: '名称',
|
||||||
searchShares: '搜索订阅分享',
|
searchShares: '搜索订阅分享',
|
||||||
@@ -3117,6 +3118,10 @@ export default {
|
|||||||
cancelSubscribe: '取消订阅',
|
cancelSubscribe: '取消订阅',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
cancelSubscribeConfirm: '是否确认取消订阅?',
|
cancelSubscribeConfirm: '是否确认取消订阅?',
|
||||||
|
updateSuccess: '{name} 更新成功!',
|
||||||
|
updateFailed: '{name} 更新失败:{message}!',
|
||||||
|
defaultSaveSuccess: '{type}订阅默认规则保存成功',
|
||||||
|
defaultSaveFailed: '{type}订阅默认规则保存失败:{message}!',
|
||||||
},
|
},
|
||||||
subscribeFiles: {
|
subscribeFiles: {
|
||||||
title: '订阅文件',
|
title: '订阅文件',
|
||||||
|
|||||||
@@ -1119,6 +1119,7 @@ export default {
|
|||||||
cancelSuccess: '已取消訂閱!',
|
cancelSuccess: '已取消訂閱!',
|
||||||
cancelFailed: '取消訂閱失敗:{message}!',
|
cancelFailed: '取消訂閱失敗:{message}!',
|
||||||
notFound: '訂閱不存在!',
|
notFound: '訂閱不存在!',
|
||||||
|
requestFailed: '請求失敗,請稍後重試',
|
||||||
filterSubscriptions: '篩選訂閱',
|
filterSubscriptions: '篩選訂閱',
|
||||||
name: '名稱',
|
name: '名稱',
|
||||||
searchShares: '搜索訂閱分享',
|
searchShares: '搜索訂閱分享',
|
||||||
@@ -3116,6 +3117,10 @@ export default {
|
|||||||
cancelSubscribe: '取消訂閱',
|
cancelSubscribe: '取消訂閱',
|
||||||
save: '儲存',
|
save: '儲存',
|
||||||
cancelSubscribeConfirm: '是否確認取消訂閱?',
|
cancelSubscribeConfirm: '是否確認取消訂閱?',
|
||||||
|
updateSuccess: '{name} 更新成功!',
|
||||||
|
updateFailed: '{name} 更新失敗:{message}!',
|
||||||
|
defaultSaveSuccess: '{type}訂閱默認規則儲存成功',
|
||||||
|
defaultSaveFailed: '{type}訂閱默認規則儲存失敗:{message}!',
|
||||||
},
|
},
|
||||||
subscribeFiles: {
|
subscribeFiles: {
|
||||||
title: '訂閱文件',
|
title: '訂閱文件',
|
||||||
|
|||||||
@@ -49,7 +49,13 @@ const MediaCardSlideViewStub = defineComponent({
|
|||||||
})
|
})
|
||||||
|
|
||||||
interface SharedDialogEvents {
|
interface SharedDialogEvents {
|
||||||
|
close: () => void
|
||||||
save: (payload?: { enabled?: Record<string, boolean> }) => Promise<void>
|
save: (payload?: { enabled?: Record<string, boolean> }) => Promise<void>
|
||||||
|
'update:modelValue': (value: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SharedDialogProps {
|
||||||
|
valueGetter: (item: { title: string }) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderRecommend(options: { superUser?: boolean; discovery?: boolean } = {}) {
|
async function renderRecommend(options: { superUser?: boolean; discovery?: boolean } = {}) {
|
||||||
@@ -179,6 +185,36 @@ describe('recommend page', () => {
|
|||||||
expect(mocks.closeDialog).toHaveBeenCalledOnce()
|
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<HTMLButtonElement>('.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([
|
it.each([
|
||||||
{ discovery: false, superUser: false, visible: false },
|
{ discovery: false, superUser: false, visible: false },
|
||||||
{ discovery: false, superUser: true, visible: true },
|
{ discovery: false, superUser: true, visible: true },
|
||||||
|
|||||||
@@ -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> = {}): 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> = {}): MediaInfo {
|
||||||
|
return createMediaInfo({ type: '电影', ...overrides })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造电视剧媒体信息。 */
|
||||||
|
export function createSubscribeTv(overrides: Partial<MediaInfo> = {}): 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> = {}): 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> = {}): DownloaderConf {
|
||||||
|
return {
|
||||||
|
config: {},
|
||||||
|
default: false,
|
||||||
|
enabled: true,
|
||||||
|
name: '测试下载器',
|
||||||
|
type: 'qbittorrent',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造下载目录配置。 */
|
||||||
|
export function createSubscribeDirectory(overrides: Partial<TransferDirectoryConf> = {}): TransferDirectoryConf {
|
||||||
|
return {
|
||||||
|
download_path: '/downloads',
|
||||||
|
name: '测试目录',
|
||||||
|
priority: 1,
|
||||||
|
storage: 'local',
|
||||||
|
transfer_type: 'link',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造订阅过滤规则组。 */
|
||||||
|
export function createSubscribeRuleGroup(overrides: Partial<FilterRuleGroup> = {}): FilterRuleGroup {
|
||||||
|
return {
|
||||||
|
name: '默认规则组',
|
||||||
|
rule_string: 'priority=1',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>
|
||||||
|
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<string, unknown>) => void = () => {},
|
||||||
|
) {
|
||||||
|
return http.post(subscribeApiUrls.create, async ({ request }) => {
|
||||||
|
const payload = (await request.json()) as Record<string, unknown>
|
||||||
|
onCreate(payload)
|
||||||
|
return jsonResponse(response, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSubscribeHandler(
|
||||||
|
response: SubscribeMutationResponse = { success: true },
|
||||||
|
status = 200,
|
||||||
|
onUpdate: (payload: Record<string, unknown>) => void = () => {},
|
||||||
|
) {
|
||||||
|
return http.put(subscribeApiUrls.update, async ({ request }) => {
|
||||||
|
const payload = (await request.json()) as Record<string, unknown>
|
||||||
|
onUpdate(payload)
|
||||||
|
return jsonResponse(response, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function querySubscribeByMediaHandler(
|
||||||
|
mediaId: string,
|
||||||
|
subscribe: Partial<Subscribe>,
|
||||||
|
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<Subscribe>,
|
||||||
|
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<string, unknown>) => void = () => {},
|
||||||
|
) {
|
||||||
|
return http.post(subscribeApiUrls.defaultConfig(type, true), async ({ request }) => {
|
||||||
|
const payload = (await request.json()) as Record<string, unknown>
|
||||||
|
onSave(payload)
|
||||||
|
return jsonResponse(response, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscribeDialogOptions {
|
||||||
|
directories?: TransferDirectoryConf[]
|
||||||
|
downloaders?: DownloaderConf[]
|
||||||
|
episodeGroups?: Record<string, unknown>[]
|
||||||
|
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)
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
+48
-4
@@ -276,15 +276,59 @@ export default defineConfig(({ mode }) => ({
|
|||||||
'src/stores/auth.ts',
|
'src/stores/auth.ts',
|
||||||
'src/pages/recommend.vue',
|
'src/pages/recommend.vue',
|
||||||
'src/views/dashboard/MediaRecommend.vue',
|
'src/views/dashboard/MediaRecommend.vue',
|
||||||
|
'src/composables/useMediaSubscribe.ts',
|
||||||
|
'src/components/dialog/SubscribeEditDialog.vue',
|
||||||
],
|
],
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'json-summary', 'html'],
|
reporter: ['text', 'json-summary', 'html'],
|
||||||
reportsDirectory: 'coverage',
|
reportsDirectory: 'coverage',
|
||||||
thresholds: {
|
thresholds: {
|
||||||
branches: 75,
|
branches: 80,
|
||||||
functions: 80,
|
functions: 85,
|
||||||
lines: 80,
|
lines: 85,
|
||||||
statements: 80,
|
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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user