diff --git a/src/composables/__tests__/useMediaSubscribe.spec.ts b/src/composables/__tests__/useMediaSubscribe.spec.ts index 2f9ea46d..27f2c069 100644 --- a/src/composables/__tests__/useMediaSubscribe.spec.ts +++ b/src/composables/__tests__/useMediaSubscribe.spec.ts @@ -234,6 +234,30 @@ describe('useMediaSubscribe entry flows', () => { expect(mocks.doneProgress).toHaveBeenCalledOnce() }) + it('keeps a successful creation successful when default configuration loading fails', async () => { + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) + const media = createSubscribeMovie({ title: '辅助查询失败电影', tmdb_id: 111 }) + const created = vi.fn() + const configQueried = vi.fn() + server.use( + createSubscribeHandler({ data: { id: 511 }, success: true }, 200, created), + defaultSubscribeConfigHandler('电影', {}, 500, configQueried), + ) + await renderSubscribeHarness({ media }) + + await fireEvent.click(screen.getByRole('button', { name: 'primary' })) + + await waitFor(() => expect(created).toHaveBeenCalledOnce()) + await waitFor(() => expect(configQueried).toHaveBeenCalledOnce()) + await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce()) + expect(screen.getByTestId('subscribed')).toHaveTextContent('true') + expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', true) + expect(mocks.toastSuccess).toHaveBeenCalledOnce() + expect(mocks.toastError).not.toHaveBeenCalled() + expect(mocks.openSharedDialog).not.toHaveBeenCalled() + consoleLog.mockRestore() + }) + 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() diff --git a/src/pages/__tests__/recommend.spec.ts b/src/pages/__tests__/recommend.spec.ts index 535500e8..4e312634 100644 --- a/src/pages/__tests__/recommend.spec.ts +++ b/src/pages/__tests__/recommend.spec.ts @@ -54,10 +54,6 @@ interface SharedDialogEvents { 'update:modelValue': (value: boolean) => void } -interface SharedDialogProps { - valueGetter: (item: { title: string }) => string -} - async function renderRecommend(options: { superUser?: boolean; discovery?: boolean } = {}) { return renderWithProviders(RecommendPage, { initialRoute: '/recommend', @@ -196,7 +192,9 @@ describe('recommend page', () => { const settingsButton = document.querySelector('.compact-fab') as HTMLButtonElement await user.click(settingsButton) - const dialogProps = mocks.openSharedDialog.mock.calls[0][1] as SharedDialogProps + const dialogProps = mocks.openSharedDialog.mock.calls[0][1] as { + valueGetter: (item: { title: string }) => string + } const firstDialogEvents = mocks.openSharedDialog.mock.calls[0][2] as SharedDialogEvents expect(dialogProps.valueGetter({ title: '流行趋势' })).toBe('流行趋势') diff --git a/src/pages/__tests__/subscribe.spec.ts b/src/pages/__tests__/subscribe.spec.ts new file mode 100644 index 00000000..50fb4140 --- /dev/null +++ b/src/pages/__tests__/subscribe.spec.ts @@ -0,0 +1,467 @@ +import SubscribePage from '@/pages/subscribe.vue' +import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton' +import { DEFAULT_PERMISSIONS } from '@/utils/permission' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { + computed, + defineComponent, + h, + nextTick, + ref, + unref, + type ComputedRef, + type Ref, +} from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + appMode: false, + openSharedDialog: vi.fn(), + registerHeaderTab: vi.fn(), + useDynamicButton: vi.fn(), +})) + +vi.mock('@/composables/useDynamicHeaderTab', () => ({ + useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }), +})) + +vi.mock('@/composables/useDynamicButton', () => ({ + useDynamicButton: (options: unknown) => mocks.useDynamicButton(options), +})) + +vi.mock('@/composables/usePWA', async () => { + const { computed } = await import('vue') + return { + usePWA: () => ({ appMode: computed(() => mocks.appMode) }), + } +}) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + +interface SubscribeBatchState { + enabled: boolean + selectedCount: number + totalCount: number + allSelected: boolean +} + +const SubscribeListViewStub = defineComponent({ + name: 'SubscribeListView', + props: { + type: String, + subid: String, + keyword: String, + statusFilter: String, + sortMode: { + type: Boolean, + default: false, + }, + sortBy: { + type: String, + default: '', + }, + active: { + type: Boolean, + default: true, + }, + }, + emits: ['update:sortMode', 'update:sortBy', 'batch-state-change'], + setup(props, { emit, expose }) { + const lastCommand = ref('none') + const batchState = ref({ + enabled: false, + selectedCount: 0, + totalCount: 0, + allSelected: false, + }) + + const runCommand = (command: string) => { + lastCommand.value = command + } + const publishBatchState = (state: SubscribeBatchState) => { + batchState.value = state + emit('batch-state-change', state) + } + + expose({ + enterBatchMode: () => runCommand('enter-batch'), + exitBatchMode: () => runCommand('exit-batch'), + toggleSelectAll: () => runCommand('toggle-select-all'), + batchEnableSubscribes: () => runCommand('batch-enable'), + batchPauseSubscribes: () => runCommand('batch-pause'), + batchDeleteSubscribes: () => runCommand('batch-delete'), + openHistoryDialog: () => runCommand('open-history'), + }) + + return () => + h('section', { 'aria-label': 'subscription list stub' }, [ + h('button', { 'data-menu-activator': 'filter-btn', type: 'button' }, 'filter activator'), + h('output', { 'aria-label': 'list type' }, props.type ?? ''), + h('output', { 'aria-label': 'list subscription id' }, props.subid ?? ''), + h('output', { 'aria-label': 'list keyword' }, props.keyword ?? ''), + h('output', { 'aria-label': 'list status filter' }, props.statusFilter ?? ''), + h('output', { 'aria-label': 'list sort mode' }, String(props.sortMode)), + h('output', { 'aria-label': 'list sort by' }, props.sortBy ?? ''), + h('output', { 'aria-label': 'list active state' }, String(props.active)), + h('output', { 'aria-label': 'list batch state' }, JSON.stringify(batchState.value)), + h('output', { 'aria-label': 'last list command' }, lastCommand.value), + h( + 'button', + { type: 'button', onClick: () => emit('update:sortMode', true) }, + 'emit sort mode on', + ), + h( + 'button', + { type: 'button', onClick: () => emit('update:sortMode', false) }, + 'emit sort mode off', + ), + h('button', { type: 'button', onClick: () => emit('update:sortBy', 'date') }, 'emit date sort'), + h( + 'button', + { + type: 'button', + onClick: () => + publishBatchState({ enabled: true, selectedCount: 2, totalCount: 3, allSelected: false }), + }, + 'publish batch selection', + ), + h( + 'button', + { + type: 'button', + onClick: () => + publishBatchState({ enabled: true, selectedCount: 3, totalCount: 3, allSelected: true }), + }, + 'publish all selected batch', + ), + ]) + }, +}) + +const SubscribePopularViewStub = defineComponent({ + name: 'SubscribePopularView', + props: { type: String }, + setup(props) { + return () => h('section', { 'aria-label': 'popular subscription stub' }, props.type ?? '') + }, +}) + +const SubscribeShareViewStub = defineComponent({ + name: 'SubscribeShareView', + props: { keyword: String }, + setup(props) { + return () => + h('section', { 'aria-label': 'shared subscription stub' }, [ + h('button', { 'data-menu-activator': 'share-filter-btn', type: 'button' }, 'share filter activator'), + h('output', { 'aria-label': 'share keyword' }, props.keyword ?? ''), + ]) + }, +}) + +type MaybeRef = T | Ref | ComputedRef + +interface HeaderButtonConfig { + icon: string + dataAttr?: string + action?: () => void + color?: MaybeRef + show?: MaybeRef +} + +interface HeaderTabConfig { + items: MaybeRef> + modelValue: Ref + appendButtons: HeaderButtonConfig[] +} + +interface DynamicButtonConfig { + icon: MaybeRef + menuItems?: MaybeRef + onClick?: () => void + show?: MaybeRef +} + +interface RenderSubscribeOptions { + appMode?: boolean + initialRoute?: string + subType?: '电影' | '电视剧' + subscribePermission?: boolean + superUser?: boolean +} + +async function renderSubscribe(options: RenderSubscribeOptions = {}) { + const subType = options.subType ?? '电影' + mocks.appMode = options.appMode ?? false + + return renderWithProviders(SubscribePage, { + initialRoute: options.initialRoute ?? `/subscribe/${subType === '电影' ? 'movie' : 'tv'}`, + initialRouteMeta: { subType }, + initialState: { + user: { + permissions: { + ...DEFAULT_PERMISSIONS, + subscribe: options.subscribePermission ?? true, + }, + superUser: options.superUser ?? false, + }, + }, + global: { + stubs: { + SubscribeListView: SubscribeListViewStub, + SubscribePopularView: SubscribePopularViewStub, + SubscribeShareView: SubscribeShareViewStub, + }, + }, + }) +} + +function getHeaderConfig() { + return mocks.registerHeaderTab.mock.calls.at(-1)?.[0] as HeaderTabConfig +} + +function getDynamicButtonConfig() { + return mocks.useDynamicButton.mock.calls.at(-1)?.[0] as DynamicButtonConfig +} + +function getHeaderButton(predicate: (button: HeaderButtonConfig) => boolean) { + const button = getHeaderConfig().appendButtons.find(predicate) + if (!button) throw new Error('Expected dynamic header button was not registered') + return button +} + +function getListOutput(label: string) { + return screen.getByLabelText(label) +} + +describe('subscribe page', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.appMode = false + }) + + it('uses movie route meta and query values to register the movie page contract', async () => { + const { router } = await renderSubscribe({ initialRoute: '/subscribe/movie?id=42' }) + + await waitFor(() => expect(getListOutput('list active state')).toHaveTextContent('true')) + const header = getHeaderConfig() + + expect(router.currentRoute.value.meta.subType).toBe('电影') + expect(unref(header.items).map(item => item.tab)).toEqual(['mysub', 'popular']) + expect(header.modelValue.value).toBe('mysub') + expect(getListOutput('list type')).toHaveTextContent('电影') + expect(getListOutput('list subscription id')).toHaveTextContent('42') + }) + + it('uses TV route meta and tab query to expose the share page contract', async () => { + const { router } = await renderSubscribe({ + initialRoute: '/subscribe/tv?tab=share&id=73', + subType: '电视剧', + }) + const header = getHeaderConfig() + + expect(router.currentRoute.value.meta.subType).toBe('电视剧') + expect(unref(header.items).map(item => item.tab)).toEqual(['mysub', 'popular', 'share']) + expect(header.modelValue.value).toBe('share') + expect(screen.getByLabelText('share keyword')).toHaveTextContent('') + }) + + it.each([ + ['movie value', '电影' as const, 'last_update', 'last_update'], + ['TV-only value', '电视剧' as const, 'lack_episode', 'lack_episode'], + ['invalid value', '电视剧' as const, 'unexpected', ''], + ['TV-only value on movies', '电影' as const, 'lack_episode', ''], + ])('normalizes stored sorting for %s', async (_case, subType, storedSort, expectedSort) => { + localStorage.setItem(`MPSubscribeSortBy:${subType}`, storedSort) + + await renderSubscribe({ subType }) + + expect(getListOutput('list sort by')).toHaveTextContent(expectedSort) + }) + + it('keeps page state usable when sort storage reads or writes fail', async () => { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage read failed') + }) + + await renderSubscribe() + + expect(getListOutput('list sort by')).toHaveTextContent('') + expect(consoleWarn).toHaveBeenCalledWith('读取订阅排序方式失败:', expect.any(Error)) + getItem.mockRestore() + + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage write failed') + }) + await fireEvent.click(screen.getByRole('button', { name: 'emit date sort' })) + + await waitFor(() => expect(getListOutput('list sort by')).toHaveTextContent('date')) + expect(consoleWarn).toHaveBeenCalledWith('保存订阅排序方式失败:', expect.any(Error)) + }) + + it('coordinates filter and sort state through header actions and list emits', async () => { + await renderSubscribe({ subType: '电视剧' }) + const filterButton = getHeaderButton(button => button.dataAttr === 'filter-btn') + const sortButton = getHeaderButton(button => button.icon === 'mdi-sort-variant') + + filterButton.action?.() + await nextTick() + const nameInput = await screen.findByPlaceholderText('名称') + await fireEvent.update(nameInput, 'Matrix') + expect(getListOutput('list keyword')).toHaveTextContent('Matrix') + + await fireEvent.click(screen.getByText('暂停')) + expect(getListOutput('list status filter')).toHaveTextContent('paused') + + sortButton.action?.() + await nextTick() + expect(getListOutput('list sort mode')).toHaveTextContent('true') + expect(getListOutput('list sort by')).toHaveTextContent('custom') + + await fireEvent.click(screen.getByRole('button', { name: 'emit sort mode off' })) + await fireEvent.click(screen.getByRole('button', { name: 'emit date sort' })) + expect(getListOutput('list sort mode')).toHaveTextContent('false') + expect(getListOutput('list sort by')).toHaveTextContent('date') + }) + + it('exits batch management before entering drag sorting', async () => { + await renderSubscribe({ appMode: true }) + const sortButton = getHeaderButton(button => button.icon === 'mdi-sort-variant') + const batchButton = getHeaderButton(button => button.icon === 'mdi-checkbox-multiple-marked-outline') + + await fireEvent.click(screen.getByRole('button', { name: 'publish batch selection' })) + expect(unref(getDynamicButtonConfig().show)).toBe(true) + + sortButton.action?.() + await nextTick() + expect(getListOutput('last list command')).toHaveTextContent('exit-batch') + expect(getListOutput('list sort mode')).toHaveTextContent('true') + expect(getListOutput('list sort by')).toHaveTextContent('custom') + expect(unref(batchButton.color)).toBe('gray') + expect(unref(getDynamicButtonConfig().show)).toBe(false) + }) + + it('delegates PWA batch actions to the list public API', async () => { + await renderSubscribe({ appMode: true }) + const batchButton = getHeaderButton(button => button.icon === 'mdi-checkbox-multiple-marked-outline') + + batchButton.action?.() + await nextTick() + expect(getListOutput('last list command')).toHaveTextContent('enter-batch') + + await fireEvent.click(screen.getByRole('button', { name: 'publish batch selection' })) + const dynamicButton = getDynamicButtonConfig() + const menuItems = unref(dynamicButton.menuItems) ?? [] + + expect(unref(dynamicButton.show)).toBe(true) + expect(unref(dynamicButton.icon)).toBe('mdi-checkbox-multiple-marked-outline') + expect(menuItems.find(item => item.titleKey === 'subscribe.batchSelectAll')?.disabled).toBe(false) + + for (const [titleKey, command] of [ + ['subscribe.batchSelectAll', 'toggle-select-all'], + ['subscribe.batchEnable', 'batch-enable'], + ['subscribe.batchPause', 'batch-pause'], + ['subscribe.batchDelete', 'batch-delete'], + ] as const) { + menuItems.find(item => item.titleKey === titleKey)?.action() + await nextTick() + expect(getListOutput('last list command')).toHaveTextContent(command) + } + + dynamicButton.onClick?.() + await nextTick() + expect(getListOutput('last list command')).toHaveTextContent('exit-batch') + }) + + it('exits batch mode when the header leaves the personal subscription tab', async () => { + await renderSubscribe({ appMode: true, subType: '电视剧' }) + await fireEvent.click(screen.getByRole('button', { name: 'publish batch selection' })) + + getHeaderConfig().modelValue.value = 'popular' + await nextTick() + + expect(getListOutput('last list command')).toHaveTextContent('exit-batch') + expect(getListOutput('list active state')).toHaveTextContent('false') + expect(unref(getDynamicButtonConfig().icon)).toBe('mdi-clipboard-edit-outline') + }) + + it('exposes administrator history and default-rule actions on desktop and PWA', async () => { + const { unmount } = await renderSubscribe({ superUser: true }) + + await waitFor(() => expect(document.querySelectorAll('.compact-fab button')).toHaveLength(2)) + const [historyButton, defaultRuleButton] = document.querySelectorAll('.compact-fab button') + + await fireEvent.click(historyButton) + expect(getListOutput('last list command')).toHaveTextContent('open-history') + await fireEvent.click(defaultRuleButton) + expect(mocks.openSharedDialog).toHaveBeenCalledWith( + expect.any(Object), + { default: true, type: '电影' }, + {}, + { closeOn: ['close', 'save'] }, + ) + unmount() + + await renderSubscribe({ appMode: true, superUser: true }) + const dynamicButton = getDynamicButtonConfig() + expect(unref(dynamicButton.show)).toBe(true) + expect(unref(dynamicButton.icon)).toBe('mdi-history') + expect(unref(dynamicButton.menuItems)?.map(item => item.titleKey)).toEqual([ + 'dialog.subscribeHistory.title', + 'dialog.subscribeEdit.titleDefault', + ]) + }) + + it.each([ + [true, true], + [false, false], + ])('gates the PWA share statistics action by subscribe permission=%s', async (permission, visible) => { + await renderSubscribe({ + appMode: true, + initialRoute: '/subscribe/tv?tab=share', + subType: '电视剧', + subscribePermission: permission, + }) + const dynamicButton = getDynamicButtonConfig() + + expect(unref(dynamicButton.show)).toBe(visible) + if (visible) { + expect(unref(dynamicButton.icon)).toBe('mdi-chart-line') + dynamicButton.onClick?.() + expect(mocks.openSharedDialog).toHaveBeenCalledWith( + expect.any(Object), + {}, + {}, + { closeOn: ['close'] }, + ) + } + }) + + it('debounces and trims share search, then cancels pending work on unmount', async () => { + const { unmount } = await renderSubscribe({ + initialRoute: '/subscribe/tv?tab=share', + subType: '电视剧', + }) + getHeaderButton(button => button.dataAttr === 'share-filter-btn').action?.() + await nextTick() + const keywordInput = await screen.findByPlaceholderText('关键词') + + vi.useFakeTimers() + await fireEvent.update(keywordInput, ' science fiction ') + expect(getListOutput('share keyword')).toHaveTextContent('') + vi.advanceTimersByTime(299) + await nextTick() + expect(getListOutput('share keyword')).toHaveTextContent('') + vi.advanceTimersByTime(1) + await nextTick() + expect(getListOutput('share keyword')).toHaveTextContent('science fiction') + + await fireEvent.update(keywordInput, 'pending') + expect(vi.getTimerCount()).toBeGreaterThan(0) + unmount() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/pages/subscribe.vue b/src/pages/subscribe.vue index 65edaae3..43fecb3f 100644 --- a/src/pages/subscribe.vue +++ b/src/pages/subscribe.vue @@ -282,12 +282,18 @@ function batchDeleteSelectedSubscribes() { subscribeListViewRef.value?.batchDeleteSubscribes() } -// 切换订阅拖拽排序模式,进入时固定使用自定义排序。 +// 批量选择与拖拽排序互斥,进入排序模式时清空批量选择。 function toggleSubscribeSortMode() { - if (!subscribeSortMode.value) { + const nextSortMode = !subscribeSortMode.value + + if (nextSortMode) { + if (subscribeBatchState.value.enabled) { + exitSubscribeBatchMode() + } subscribeSortBy.value = 'custom' } - subscribeSortMode.value = !subscribeSortMode.value + + subscribeSortMode.value = nextSortMode } const shareKeywordUpdater = debounce((keyword: string) => { diff --git a/src/views/subscribe/SubscribeListView.vue b/src/views/subscribe/SubscribeListView.vue index 7970942c..7277f701 100644 --- a/src/views/subscribe/SubscribeListView.vue +++ b/src/views/subscribe/SubscribeListView.vue @@ -76,6 +76,9 @@ let isRefreshed = ref(false) // 刷新状态 const loading = ref(false) +// 最近一次列表请求是否失败,用于保留旧数据时持续展示错误状态。 +const loadError = ref(false) + // 数据列表 const dataList = ref([]) @@ -93,7 +96,7 @@ const normalizedKeyword = computed(() => props.keyword?.trim().toLowerCase() || const selectedSubscribesSet = computed(() => new Set(selectedSubscribes.value)) const hasCustomOrder = computed(() => orderConfig.value.length > 0) const isAllSubscribesSelected = computed( - () => displayList.value.length > 0 && selectedSubscribes.value.length === displayList.value.length, + () => displayList.value.length > 0 && displayList.value.every(item => selectedSubscribesSet.value.has(item.id)), ) // 归一化订阅排序方式,电影订阅不使用缺失集数排序。 @@ -253,6 +256,8 @@ watch( sortSubscribeList(nextDisplayList) displayList.value = nextDisplayList + const visibleIds = new Set(nextDisplayList.map(item => item.id)) + selectedSubscribes.value = selectedSubscribes.value.filter(id => visibleIds.has(id)) }, { immediate: true }, ) @@ -290,31 +295,44 @@ async function loadSubscribeOrderConfig() { // 保存顺序设置 async function saveSubscribeOrder() { - // 顺序配置 + const confirmedOrder = orderConfig.value.map(item => ({ ...item })) const orderObj = displayList.value.map(item => ({ id: item.id })) orderConfig.value = orderObj emit('update:sortBy', 'custom') - // 保存到服务端 try { await api.post(`/user/config/${orderRequestKey.value}`, orderObj) } catch (error) { console.error(error) + orderConfig.value = confirmedOrder + const restoredDisplayList = [...displayList.value] + sortSubscribeList(restoredDisplayList) + displayList.value = restoredDisplayList + $toast.error(t('subscribe.requestFailed')) } } // 获取订阅列表数据 async function fetchData(context: KeepAliveRefreshContext = {}) { const showLoading = !context.silent || !isRefreshed.value + const isInitialLoad = !isRefreshed.value try { if (showLoading) { loading.value = true } dataList.value = await api.get('subscribe/') + loadError.value = false isRefreshed.value = true } catch (error) { console.error(error) + loadError.value = true + if (isInitialLoad) { + isRefreshed.value = true + } + if (!context.silent || isInitialLoad) { + $toast.error(t('subscribe.requestFailed')) + } } finally { if (showLoading) { loading.value = false @@ -443,10 +461,14 @@ async function batchEnableSubscribes() { try { loading.value = true - const promises = selectedSubscribes.value.map(id => api.put(`subscribe/status/${id}?state=R`)) + const promises = selectedSubscribes.value.map( + id => api.put(`subscribe/status/${id}?state=R`) as unknown as Promise<{ success: boolean }>, + ) const results = await Promise.allSettled(promises) - const successCount = results.filter(result => result.status === 'fulfilled').length + const successCount = results.filter( + result => result.status === 'fulfilled' && result.value?.success === true, + ).length const failedCount = results.length - successCount if (successCount > 0) { @@ -482,10 +504,14 @@ async function batchPauseSubscribes() { try { loading.value = true - const promises = selectedSubscribes.value.map(id => api.put(`subscribe/status/${id}?state=S`)) + const promises = selectedSubscribes.value.map( + id => api.put(`subscribe/status/${id}?state=S`) as unknown as Promise<{ success: boolean }>, + ) const results = await Promise.allSettled(promises) - const successCount = results.filter(result => result.status === 'fulfilled').length + const successCount = results.filter( + result => result.status === 'fulfilled' && result.value?.success === true, + ).length const failedCount = results.length - successCount if (successCount > 0) { @@ -554,6 +580,10 @@ defineExpose({ ({ + confirm: vi.fn(), + openSharedDialog: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), + toastWarning: vi.fn(), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ + error: mocks.toastError, + success: mocks.toastSuccess, + warning: mocks.toastWarning, + }), +})) + +vi.mock('@/composables/useConfirm', () => ({ + useConfirm: () => mocks.confirm, +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + +const SubscribeCardStub = defineComponent({ + name: 'SubscribeCard', + props: { + batchMode: Boolean, + media: { type: Object as PropType, required: true }, + selected: Boolean, + sortable: Boolean, + }, + emits: ['remove', 'save', 'select'], + setup(props, { emit }) { + return () => + h( + 'article', + { + 'data-batch': String(props.batchMode), + 'data-page-open': String(Boolean(props.media.page_open)), + 'data-selected': String(props.selected), + 'data-sortable': String(props.sortable), + 'data-testid': `subscribe-card-${props.media.id}`, + }, + [ + h('span', props.media.name), + h('button', { 'aria-label': `select-${props.media.id}`, onClick: () => emit('select'), type: 'button' }, 'select'), + h('button', { 'aria-label': `save-${props.media.id}`, onClick: () => emit('save'), type: 'button' }, 'save'), + h('button', { 'aria-label': `remove-${props.media.id}`, onClick: () => emit('remove'), type: 'button' }, 'remove'), + ], + ) + }, +}) + +const ProgressiveCardGridStub = defineComponent({ + name: 'ProgressiveCardGrid', + props: { + items: { type: Array as PropType, required: true }, + scrollToIndex: Number, + }, + setup(props, { slots }) { + return () => + h( + 'section', + { + 'data-scroll-to-index': props.scrollToIndex ?? '', + 'data-testid': 'progressive-grid', + }, + props.items.flatMap(item => slots.default?.({ item }) ?? []), + ) + }, +}) + +const DraggableStub = defineComponent({ + name: 'Draggable', + props: { + modelValue: { type: Array as PropType, required: true }, + }, + emits: ['end', 'update:modelValue'], + setup(props, { emit, slots }) { + async function reverseOrder() { + emit('update:modelValue', [...props.modelValue].reverse()) + await nextTick() + emit('end') + } + + return () => + h('section', { 'data-testid': 'draggable-list' }, [ + ...props.modelValue.flatMap(element => slots.item?.({ element }) ?? []), + h('button', { onClick: reverseOrder, type: 'button' }, 'reverse-order'), + ]) + }, +}) + +const LoadingBannerStub = defineComponent({ + name: 'LoadingBanner', + template: '
loading
', +}) + +const NoDataFoundStub = defineComponent({ + name: 'NoDataFound', + props: { + errorDescription: String, + errorTitle: String, + }, + template: '
{{ errorTitle }} {{ errorDescription }}
', +}) + +interface BatchState { + allSelected: boolean + enabled: boolean + selectedCount: number + totalCount: number +} + +interface ListActions { + batchDeleteSubscribes: () => Promise + batchEnableSubscribes: () => Promise + batchPauseSubscribes: () => Promise + enterBatchMode: () => void + exitBatchMode: () => void + openHistoryDialog: () => void + toggleBatchMode: () => void + toggleSelectAll: () => void +} + +const SubscribeListHost = defineComponent({ + name: 'SubscribeListHost', + components: { SubscribeListView }, + props: { + active: { type: Boolean, default: true }, + keyword: { type: String, default: '' }, + sortBy: { type: String, default: '' }, + sortMode: { type: Boolean, default: false }, + statusFilter: { type: String, default: 'all' }, + subid: { type: String, default: '' }, + type: { type: String as PropType, default: '电影' }, + }, + setup(props) { + const list = ref(null) + const currentSortBy = ref(props.sortBy) + const currentSortMode = ref(props.sortMode) + const batchState = ref({ allSelected: false, enabled: false, selectedCount: 0, totalCount: 0 }) + + watch( + () => props.sortBy, + value => { + currentSortBy.value = value + }, + ) + watch( + () => props.sortMode, + value => { + currentSortMode.value = value + }, + ) + + function call(action: keyof ListActions) { + return list.value?.[action]() + } + + return { batchState, call, currentSortBy, currentSortMode, list } + }, + template: ` + + + + + + + + + + {{ JSON.stringify(batchState) }} + {{ currentSortBy }} + {{ String(currentSortMode) }} + `, +}) + +interface RenderListOptions { + active?: boolean + keyword?: string + listResponse?: Subscribe[] + listStatus?: number + onListRequest?: (url: URL) => void + onOrderRequest?: (url: URL) => void + orderStatus?: number + orderValue?: Parameters[1] + sortBy?: string + sortMode?: boolean + statusFilter?: string + subid?: string + superUser?: boolean + type?: SubscribeMediaType + userName?: string +} + +async function renderList(options: RenderListOptions = {}) { + const type = options.type ?? '电影' + server.use( + subscribeOrderConfigHandler( + type, + options.orderValue, + options.orderStatus ?? 200, + options.onOrderRequest, + ), + subscribeListHandler(options.listResponse ?? [], options.listStatus ?? 200, options.onListRequest), + ) + + return renderWithProviders(SubscribeListHost, { + props: { + active: options.active ?? true, + keyword: options.keyword ?? '', + sortBy: options.sortBy ?? '', + sortMode: options.sortMode ?? false, + statusFilter: options.statusFilter ?? 'all', + subid: options.subid ?? '', + type, + }, + initialState: { + user: { + superUser: options.superUser ?? false, + userName: options.userName ?? 'tester', + }, + }, + global: { + stubs: { + Draggable: DraggableStub, + LoadingBanner: LoadingBannerStub, + NoDataFound: NoDataFoundStub, + ProgressiveCardGrid: ProgressiveCardGridStub, + SubscribeCard: SubscribeCardStub, + draggable: DraggableStub, + }, + }, + }) +} + +function movie(id: number, name: string, overrides: Partial = {}) { + return createSubscribe({ id, name, type: '电影', username: 'tester', ...overrides }) +} + +function tv(id: number, name: string, overrides: Partial = {}) { + return createSubscribe({ id, name, type: '电视剧', username: 'tester', ...overrides }) +} + +function card(id: number) { + return screen.getByTestId(`subscribe-card-${id}`) +} + +function displayedNames() { + return screen.queryAllByTestId(/^subscribe-card-/).map(element => element.querySelector('span')?.textContent) +} + +function batchState(): BatchState { + return JSON.parse(screen.getByTestId('batch-state').textContent || '{}') as BatchState +} + +beforeEach(() => { + Object.values(mocks).forEach(mock => mock.mockReset()) + mocks.confirm.mockResolvedValue(true) + mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() }) +}) + +describe('SubscribeListView loading and filtering', () => { + it('loads exact endpoints and restricts a normal user by owner and media type', async () => { + const listRequested = vi.fn() + const orderRequested = vi.fn() + await renderList({ + listResponse: [movie(1, 'Own movie'), movie(2, 'Other movie', { username: 'other' }), tv(3, 'Own TV')], + onListRequest: listRequested, + onOrderRequest: orderRequested, + }) + + expect(await screen.findByText('Own movie')).toBeInTheDocument() + expect(screen.queryByText('Other movie')).not.toBeInTheDocument() + expect(screen.queryByText('Own TV')).not.toBeInTheDocument() + expect(orderRequested.mock.calls[0][0].href).toBe(subscribeApiUrls.orderConfig('电影')) + expect(listRequested.mock.calls[0][0].href).toBe(subscribeApiUrls.list) + expect(screen.getByTestId('sort-by-state')).toHaveTextContent('date') + }) + + it('lets a superuser see subscriptions from every owner while retaining type defense', async () => { + await renderList({ + listResponse: [movie(1, 'Own movie'), movie(2, 'Other movie', { username: 'other' }), tv(3, 'Other TV')], + superUser: true, + }) + + expect(await screen.findByText('Own movie')).toBeInTheDocument() + expect(screen.getByText('Other movie')).toBeInTheDocument() + expect(screen.queryByText('Other TV')).not.toBeInTheDocument() + }) + + it('normalizes keyword filtering', async () => { + await renderList({ keyword: ' ALPHA ', listResponse: [movie(1, 'Alpha One'), movie(2, 'Beta Two')] }) + + expect(await screen.findByText('Alpha One')).toBeInTheDocument() + expect(screen.queryByText('Beta Two')).not.toBeInTheDocument() + }) + + it.each([ + ['best_version', 'Best'], + ['pending', 'Pending'], + ['paused', 'Paused'], + ['completed', 'Completed'], + ['subscribing', 'Subscribing'], + ['not_started', 'Not started'], + ])('derives the %s status defensively', async (statusFilter, expectedName) => { + const subscriptions = [ + tv(11, 'Best', { best_version: 1 }), + tv(12, 'Pending', { state: 'P' }), + tv(13, 'Paused', { state: 'S' }), + tv(14, 'Completed', { completed_episode: 10, lack_episode: 0, total_episode: 10 }), + tv(15, 'Subscribing', { completed_episode: 6, lack_episode: 4, total_episode: 10 }), + tv(16, 'Not started', { completed_episode: 0, lack_episode: 10, total_episode: 10 }), + ] + await renderList({ listResponse: subscriptions, statusFilter, type: '电视剧' }) + + expect(await screen.findByText(expectedName)).toBeInTheDocument() + expect(displayedNames()).toEqual([expectedName]) + }) + + it('shows the empty state after a successful empty list response', async () => { + await renderList({ listResponse: [] }) + + expect(await screen.findByTestId('no-data')).toBeInTheDocument() + expect(screen.queryByTestId('loading-banner')).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('finishes the initial loading state and shows a visible error when the list request fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const listRequested = vi.fn() + await renderList({ listResponse: [], listStatus: 500, onListRequest: listRequested }) + + await waitFor(() => expect(listRequested).toHaveBeenCalledOnce()) + await waitFor(() => expect(screen.queryByTestId('loading-banner')).not.toBeInTheDocument()) + expect(screen.getByRole('alert')).toHaveTextContent('请求失败,请稍后重试') + expect(screen.queryByTestId('no-data')).not.toBeInTheDocument() + expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试') + }) + + it('keeps old data through a silent refresh failure and clears the error after recovery', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const failedRequest = vi.fn() + const recoveredRequest = vi.fn() + const { rerender } = await renderList({ listResponse: [movie(1, 'Cached movie')] }) + await screen.findByText('Cached movie') + + await rerender({ active: false }) + server.use(subscribeListHandler([], 500, failedRequest)) + await rerender({ active: true }) + + await waitFor(() => expect(failedRequest).toHaveBeenCalledOnce()) + expect(screen.getByText('Cached movie')).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('请求失败,请稍后重试') + expect(screen.queryByTestId('loading-banner')).not.toBeInTheDocument() + + await rerender({ active: false }) + server.use(subscribeListHandler([movie(2, 'Recovered movie')], 200, recoveredRequest)) + await rerender({ active: true }) + + await waitFor(() => expect(recoveredRequest).toHaveBeenCalledOnce()) + expect(await screen.findByText('Recovered movie')).toBeInTheDocument() + expect(screen.queryByText('Cached movie')).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) +}) + +describe('SubscribeListView sorting and refresh boundaries', () => { + it('applies custom order first and appends unconfigured subscriptions by date', async () => { + await renderList({ + listResponse: [ + movie(1, 'Old unconfigured', { date: '2024-01-01' }), + movie(2, 'Configured', { date: '2023-01-01' }), + movie(3, 'New unconfigured', { date: '2025-01-01' }), + ], + orderValue: [{ id: 2 }], + sortBy: 'custom', + }) + + await screen.findByText('Configured') + expect(displayedNames()).toEqual(['Configured', 'New unconfigured', 'Old unconfigured']) + }) + + it.each([ + [ + 'date', + [movie(1, 'Invalid date', { date: 'not-a-date' }), movie(2, 'Newest', { date: '2025-02-01' })], + ['Newest', 'Invalid date'], + ], + [ + 'last_update', + [movie(1, 'Invalid update', { last_update: 'bad' }), movie(2, 'Latest update', { last_update: '2025-02-01' })], + ['Latest update', 'Invalid update'], + ], + [ + 'lack_episode', + [ + tv(1, 'Few missing', { date: '2025-03-01', lack_episode: 1 }), + tv(2, 'Many missing', { date: '2024-01-01', lack_episode: 8 }), + tv(3, 'Few newer', { date: '2025-04-01', lack_episode: 1 }), + ], + ['Many missing', 'Few newer', 'Few missing'], + ], + ])('sorts by %s and treats invalid dates as zero', async (sortBy, subscriptions, expected) => { + await renderList({ + listResponse: subscriptions, + sortBy, + type: sortBy === 'lack_episode' ? '电视剧' : '电影', + }) + + await screen.findByText(expected[0]) + expect(displayedNames()).toEqual(expected) + }) + + it('marks and scrolls to the initial subscription id', async () => { + await renderList({ listResponse: [movie(1, 'First'), movie(2, 'Target')], subid: '2' }) + + await screen.findByText('Target') + expect(card(2)).toHaveAttribute('data-page-open', 'true') + expect(screen.getByTestId('progressive-grid')).toHaveAttribute('data-scroll-to-index', '1') + }) + + it('refreshes from card save/remove and the history save boundary', async () => { + const listRequested = vi.fn() + await renderList({ listResponse: [movie(1, 'Refresh target')], onListRequest: listRequested }) + await screen.findByText('Refresh target') + expect(listRequested).toHaveBeenCalledTimes(1) + + await fireEvent.click(screen.getByRole('button', { name: 'save-1' })) + await waitFor(() => expect(listRequested).toHaveBeenCalledTimes(2)) + await fireEvent.click(screen.getByRole('button', { name: 'remove-1' })) + await waitFor(() => expect(listRequested).toHaveBeenCalledTimes(3)) + await fireEvent.click(screen.getByRole('button', { name: 'host-open-history' })) + + expect(mocks.openSharedDialog).toHaveBeenCalledOnce() + expect(mocks.openSharedDialog.mock.calls[0][1]).toEqual({ type: '电影' }) + const events = mocks.openSharedDialog.mock.calls[0][2] as { save: () => void } + events.save() + await waitFor(() => expect(listRequested).toHaveBeenCalledTimes(4)) + }) + + it('commits a custom order only after a successful response', async () => { + const saved = vi.fn() + server.use(saveSubscribeOrderConfigHandler('电影', { success: true }, 200, saved)) + await renderList({ + listResponse: [movie(1, 'First'), movie(2, 'Second')], + orderValue: [{ id: 1 }, { id: 2 }], + sortBy: 'custom', + sortMode: true, + }) + await screen.findByText('First') + + await fireEvent.click(screen.getByRole('button', { name: 'reverse-order' })) + + await waitFor(() => expect(saved).toHaveBeenCalledOnce()) + expect(saved.mock.calls[0][0]).toEqual([{ id: 2 }, { id: 1 }]) + expect(saved.mock.calls[0][1].href).toBe(subscribeApiUrls.orderConfig('电影')) + expect(displayedNames()).toEqual(['Second', 'First']) + expect(screen.getByTestId('sort-mode-state')).toHaveTextContent('true') + }) + + it('rolls back the confirmed order and remains sortable after a request failure', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + server.use(saveSubscribeOrderConfigHandler('电影', { message: 'server error', success: false }, 500)) + await renderList({ + listResponse: [movie(1, 'First'), movie(2, 'Second')], + orderValue: [{ id: 1 }, { id: 2 }], + sortBy: 'custom', + sortMode: true, + }) + await screen.findByText('First') + + await fireEvent.click(screen.getByRole('button', { name: 'reverse-order' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试')) + expect(displayedNames()).toEqual(['First', 'Second']) + expect(screen.getByTestId('sort-mode-state')).toHaveTextContent('true') + }) +}) + +describe('SubscribeListView batch operations', () => { + it('exits drag sorting when batch mode makes the list unsortable', async () => { + await renderList({ + listResponse: [movie(1, 'Alpha'), movie(2, 'Beta')], + orderValue: [{ id: 1 }, { id: 2 }], + sortBy: 'custom', + sortMode: true, + }) + await screen.findByText('Alpha') + expect(screen.getByTestId('sort-mode-state')).toHaveTextContent('true') + + await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' })) + + await waitFor(() => expect(screen.getByTestId('sort-mode-state')).toHaveTextContent('false')) + expect(batchState()).toMatchObject({ enabled: true, selectedCount: 0, totalCount: 2 }) + }) + + it('intersects selection with the visible list and never treats equal lengths as equal ids', async () => { + const statusRequested = vi.fn() + server.use(updateSubscribeStatusHandler(1, { success: true }, 200, statusRequested)) + const { rerender } = await renderList({ listResponse: [movie(1, 'Alpha'), movie(2, 'Beta')] }) + await screen.findByText('Alpha') + + await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' })) + await fireEvent.click(screen.getByRole('button', { name: 'select-1' })) + expect(batchState()).toMatchObject({ allSelected: false, enabled: true, selectedCount: 1, totalCount: 2 }) + + await rerender({ keyword: 'Beta' }) + await waitFor(() => expect(displayedNames()).toEqual(['Beta'])) + expect(card(2)).toHaveAttribute('data-selected', 'false') + expect(batchState()).toMatchObject({ allSelected: false, enabled: true, selectedCount: 0, totalCount: 1 }) + + await fireEvent.click(screen.getByRole('button', { name: 'host-batch-enable' })) + expect(mocks.toastWarning).toHaveBeenCalledWith('请先选择要操作的订阅') + expect(statusRequested).not.toHaveBeenCalled() + }) + + it('selects and deselects the exact visible id set', async () => { + await renderList({ listResponse: [movie(1, 'Alpha'), movie(2, 'Beta')] }) + await screen.findByText('Alpha') + await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' })) + + await fireEvent.click(screen.getByRole('button', { name: 'host-toggle-select-all' })) + expect(card(1)).toHaveAttribute('data-selected', 'true') + expect(card(2)).toHaveAttribute('data-selected', 'true') + expect(batchState()).toMatchObject({ allSelected: true, selectedCount: 2, totalCount: 2 }) + + await fireEvent.click(screen.getByRole('button', { name: 'host-toggle-select-all' })) + expect(card(1)).toHaveAttribute('data-selected', 'false') + expect(card(2)).toHaveAttribute('data-selected', 'false') + expect(batchState()).toMatchObject({ allSelected: false, selectedCount: 0, totalCount: 2 }) + }) + + it('does not request a mutation without selection or after confirmation is cancelled', async () => { + const requested = vi.fn() + server.use(updateSubscribeStatusHandler(1, { success: true }, 200, requested)) + await renderList({ listResponse: [movie(1, 'Alpha')] }) + await screen.findByText('Alpha') + await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' })) + + await fireEvent.click(screen.getByRole('button', { name: 'host-batch-enable' })) + expect(mocks.toastWarning).toHaveBeenCalledWith('请先选择要操作的订阅') + expect(requested).not.toHaveBeenCalled() + + mocks.confirm.mockResolvedValueOnce(false) + await fireEvent.click(screen.getByRole('button', { name: 'select-1' })) + await fireEvent.click(screen.getByRole('button', { name: 'host-batch-enable' })) + expect(requested).not.toHaveBeenCalled() + expect(card(1)).toHaveAttribute('data-selected', 'true') + }) + + it.each([ + ['enable', 'host-batch-enable', 'R'], + ['pause', 'host-batch-pause', 'S'], + ])('completes a successful batch %s and sends the expected state query', async (_case, buttonName, state) => { + const requested = vi.fn() + server.use(updateSubscribeStatusHandler(1, { success: true }, 200, requested)) + await renderList({ listResponse: [movie(1, 'Alpha')] }) + await screen.findByText('Alpha') + await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' })) + await fireEvent.click(screen.getByRole('button', { name: 'select-1' })) + + await fireEvent.click(screen.getByRole('button', { name: buttonName })) + + await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + expect(requested.mock.calls[0][0].pathname).toBe(new URL(subscribeApiUrls.statusById(1)).pathname) + expect(requested.mock.calls[0][0].searchParams.get('state')).toBe(state) + await waitFor(() => expect(batchState()).toMatchObject({ enabled: false, selectedCount: 0 })) + }) + + it('completes a successful batch delete through the id endpoint', async () => { + const deleted = vi.fn() + server.use(deleteSubscribeByIdHandler(1, { success: true }, 200, deleted)) + await renderList({ listResponse: [movie(1, 'Alpha')] }) + await screen.findByText('Alpha') + await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' })) + await fireEvent.click(screen.getByRole('button', { name: 'select-1' })) + + await fireEvent.click(screen.getByRole('button', { name: 'host-batch-delete' })) + + await waitFor(() => expect(deleted).toHaveBeenCalledOnce()) + await waitFor(() => expect(batchState()).toMatchObject({ enabled: false, selectedCount: 0 })) + }) + + it.each([ + ['enable', 'host-batch-enable', '启用'], + ['pause', 'host-batch-pause', '暂停'], + ])('classifies a %s success false response as a failure', async (_case, buttonName, actionName) => { + const requested = vi.fn() + server.use(updateSubscribeStatusHandler(1, { message: 'rejected', success: false }, 200, requested)) + await renderList({ listResponse: [movie(1, 'Alpha')] }) + await screen.findByText('Alpha') + await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' })) + await fireEvent.click(screen.getByRole('button', { name: 'select-1' })) + + await fireEvent.click(screen.getByRole('button', { name: buttonName })) + + await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(`${actionName}失败 1 个订阅`)) + expect(mocks.toastSuccess).not.toHaveBeenCalled() + await waitFor(() => expect(batchState()).toMatchObject({ enabled: false, selectedCount: 0 })) + }) + + it('reports mixed status results without changing the existing completion flow', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const firstRequested = vi.fn() + const secondRequested = vi.fn() + server.use( + updateSubscribeStatusHandler(1, { success: true }, 200, firstRequested), + updateSubscribeStatusHandler(2, { message: 'failed', success: false }, 500, secondRequested), + ) + await renderList({ listResponse: [movie(1, 'Alpha'), movie(2, 'Beta')] }) + await screen.findByText('Alpha') + await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' })) + await fireEvent.click(screen.getByRole('button', { name: 'host-toggle-select-all' })) + + await fireEvent.click(screen.getByRole('button', { name: 'host-batch-enable' })) + + await waitFor(() => expect(firstRequested).toHaveBeenCalledOnce()) + await waitFor(() => expect(secondRequested).toHaveBeenCalledOnce()) + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('成功启用 1 个订阅')) + expect(mocks.toastError).toHaveBeenCalledWith('启用失败 1 个订阅') + await waitFor(() => expect(batchState()).toMatchObject({ enabled: false, selectedCount: 0 })) + }) +}) diff --git a/tests/support/msw/handlers/subscribe.ts b/tests/support/msw/handlers/subscribe.ts index fe877709..b0984068 100644 --- a/tests/support/msw/handlers/subscribe.ts +++ b/tests/support/msw/handlers/subscribe.ts @@ -26,7 +26,11 @@ export const subscribeApiUrls = { 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, + list: new URL('subscribe/', API_BASE_URL).href, + orderConfig: (type: SubscribeMediaType) => + new URL(`user/config/${type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'}`, API_BASE_URL).href, sites: new URL('site/rss', API_BASE_URL).href, + statusById: (id: number) => new URL(`subscribe/status/${id}`, API_BASE_URL).href, update: new URL('subscribe/', API_BASE_URL).href, } @@ -34,6 +38,54 @@ function jsonResponse(body: JsonBodyType, status: number) { return HttpResponse.json(body, { status }) } +export function subscribeListHandler( + response: JsonBodyType = [], + status = 200, + onRequest: (url: URL) => void = () => {}, +) { + return http.get(subscribeApiUrls.list, ({ request }) => { + onRequest(new URL(request.url)) + return jsonResponse(response, status) + }) +} + +export function subscribeOrderConfigHandler( + type: SubscribeMediaType, + value: JsonBodyType = [], + status = 200, + onRequest: (url: URL) => void = () => {}, +) { + return http.get(subscribeApiUrls.orderConfig(type), ({ request }) => { + onRequest(new URL(request.url)) + return jsonResponse({ data: { value }, success: status < 400 }, status) + }) +} + +export function saveSubscribeOrderConfigHandler( + type: SubscribeMediaType, + response: SubscribeMutationResponse = { success: true }, + status = 200, + onSave: (payload: { id: number }[], url: URL) => void | Promise = () => {}, +) { + return http.post(subscribeApiUrls.orderConfig(type), async ({ request }) => { + const payload = (await request.json()) as { id: number }[] + await onSave(payload, new URL(request.url)) + return jsonResponse(response, status) + }) +} + +export function updateSubscribeStatusHandler( + id: number, + response: SubscribeMutationResponse = { success: true }, + status = 200, + onRequest: (url: URL) => void | Promise = () => {}, +) { + return http.put(subscribeApiUrls.statusById(id), async ({ request }) => { + await onRequest(new URL(request.url)) + return jsonResponse(response, status) + }) +} + export function createSubscribeHandler( response: SubscribeMutationResponse = { data: { id: 1 }, success: true }, status = 200, diff --git a/tests/support/render.ts b/tests/support/render.ts index 0fa0cfcf..472a49a5 100644 --- a/tests/support/render.ts +++ b/tests/support/render.ts @@ -4,7 +4,7 @@ import { createTestingPinia } from '@pinia/testing' import { render } from '@testing-library/vue' import { setActivePinia } from 'pinia' import { defineComponent, h, type Component } from 'vue' -import { createMemoryHistory, createRouter, type RouteLocationRaw } from 'vue-router' +import { createMemoryHistory, createRouter, type RouteLocationRaw, type RouteMeta } from 'vue-router' import { vi } from 'vitest' type TestingLibraryRenderOptions = NonNullable[1]> @@ -12,6 +12,7 @@ type TestingLibraryRenderOptions = NonNullable[1]> export interface RenderWithProvidersOptions extends Omit { global?: TestingLibraryRenderOptions['global'] initialRoute?: RouteLocationRaw + initialRouteMeta?: RouteMeta initialState?: Record> stubActions?: boolean } @@ -26,13 +27,14 @@ export async function renderWithProviders(component: Component, options: RenderW const { global: globalOptions, initialRoute = '/', + initialRouteMeta = {}, initialState = {}, stubActions = true, ...renderOptions } = options const router = createRouter({ history: createMemoryHistory(), - routes: [{ path: '/:pathMatch(.*)*', component: EmptyRoute }], + routes: [{ path: '/:pathMatch(.*)*', component: EmptyRoute, meta: initialRouteMeta }], }) await router.push(initialRoute) i18n.global.locale.value = 'zh-CN' diff --git a/vite.config.ts b/vite.config.ts index 5e0582a7..6d382509 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -268,6 +268,7 @@ export default defineConfig(({ mode }) => ({ }, }, setupFiles: ['./tests/setup.ts'], + testTimeout: 60_000, unstubGlobals: true, coverage: { include: [ @@ -275,7 +276,9 @@ export default defineConfig(({ mode }) => ({ 'src/utils/permission.ts', 'src/stores/auth.ts', 'src/pages/recommend.vue', + 'src/pages/subscribe.vue', 'src/views/dashboard/MediaRecommend.vue', + 'src/views/subscribe/SubscribeListView.vue', 'src/composables/useMediaSubscribe.ts', 'src/components/dialog/SubscribeEditDialog.vue', ], @@ -305,6 +308,12 @@ export default defineConfig(({ mode }) => ({ lines: 80, statements: 80, }, + 'src/pages/subscribe.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, 'src/stores/auth.ts': { branches: 75, functions: 80, @@ -329,6 +338,12 @@ export default defineConfig(({ mode }) => ({ lines: 80, statements: 80, }, + 'src/views/subscribe/SubscribeListView.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, }, }, },