From 435e9ecfdd4febf791fd581b3be9233816cebf7f Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:54:24 +0800 Subject: [PATCH] test(subscribe): cover popular and share workflows (#545) --- src/api/types.ts | 2 + src/components/cards/SubscribeShareCard.vue | 3 +- .../__tests__/SubscribeShareCard.spec.ts | 180 +++++++ src/components/dialog/ForkSubscribeDialog.vue | 19 +- .../dialog/SubscribeShareDialog.vue | 21 +- .../dialog/SubscribeShareStatisticsDialog.vue | 12 + .../__tests__/ForkSubscribeDialog.spec.ts | 395 ++++++++++++++++ .../__tests__/SubscribeShareDialog.spec.ts | 170 +++++++ .../SubscribeShareStatisticsDialog.spec.ts | 174 +++++++ src/views/subscribe/SubscribePopularView.vue | 120 ++--- src/views/subscribe/SubscribeShareView.vue | 105 ++--- .../__tests__/SubscribePopularView.spec.ts | 401 ++++++++++++++++ .../__tests__/SubscribeShareView.spec.ts | 441 ++++++++++++++++++ tests/support/factories/subscribe.ts | 39 ++ tests/support/msw/handlers/subscribe.ts | 121 ++++- vite.config.ts | 42 ++ 16 files changed, 2122 insertions(+), 123 deletions(-) create mode 100644 src/components/cards/__tests__/SubscribeShareCard.spec.ts create mode 100644 src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts create mode 100644 src/components/dialog/__tests__/SubscribeShareDialog.spec.ts create mode 100644 src/components/dialog/__tests__/SubscribeShareStatisticsDialog.spec.ts create mode 100644 src/views/subscribe/__tests__/SubscribePopularView.spec.ts create mode 100644 src/views/subscribe/__tests__/SubscribeShareView.spec.ts diff --git a/src/api/types.ts b/src/api/types.ts index e7f3c2c3..395271b3 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -114,6 +114,8 @@ export interface SubscribeShare { tmdbid?: number // 豆瓣ID doubanid?: string + // Bangumi ID + bangumiid?: number // 季号 season?: number // 海报 diff --git a/src/components/cards/SubscribeShareCard.vue b/src/components/cards/SubscribeShareCard.vue index 00e2d399..3699812a 100644 --- a/src/components/cards/SubscribeShareCard.vue +++ b/src/components/cards/SubscribeShareCard.vue @@ -49,6 +49,7 @@ const posterUrl = computed(() => { function getMediaId() { if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}` else if (props.media?.doubanid) return `douban:${props.media?.doubanid}` + else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}` } // 查看媒体详情 @@ -152,7 +153,7 @@ function doDelete() { - + {{ dateText }} diff --git a/src/components/cards/__tests__/SubscribeShareCard.spec.ts b/src/components/cards/__tests__/SubscribeShareCard.spec.ts new file mode 100644 index 00000000..050056e8 --- /dev/null +++ b/src/components/cards/__tests__/SubscribeShareCard.spec.ts @@ -0,0 +1,180 @@ +import { formatDateDifference } from '@/@core/utils/formatters' +import type { SubscribeShare } from '@/api/types' +import SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { createSubscribeShare } from '@tests/support/factories/subscribe' +import { renderWithProviders } from '@tests/support/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + openSharedDialog: vi.fn(), + routerPush: vi.fn(), +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + +vi.mock('@/router', () => ({ + default: { push: (...args: unknown[]) => mocks.routerPush(...args) }, +})) + +vi.mock('@iconify/vue', () => ({ + Icon: { + props: ['icon'], + template: '', + }, +})) + +type ShareOverrides = Partial + +function observeElementsImmediately() { + class ImmediateIntersectionObserver { + readonly root = null + readonly rootMargin = '0px' + readonly thresholds = [0] + + constructor(private readonly callback: IntersectionObserverCallback) {} + + disconnect() {} + + observe(target: Element) { + this.callback([{ intersectionRatio: 1, isIntersecting: true, target } as IntersectionObserverEntry], this) + } + + takeRecords(): IntersectionObserverEntry[] { + return [] + } + + unobserve() {} + } + + vi.stubGlobal('IntersectionObserver', ImmediateIntersectionObserver) +} + +async function renderCard(overrides: ShareOverrides = {}, globalImageCache = false) { + const media = createSubscribeShare(overrides) + const result = await renderWithProviders(SubscribeShareCard, { + initialState: { + globalSettings: { + data: { GLOBAL_IMAGE_CACHE: globalImageCache }, + initialized: true, + loading: false, + }, + }, + props: { media }, + }) + + return { ...result, media } +} + +async function loadPoster(container: Element) { + const backdrop = container.querySelector('img') + expect(backdrop).not.toBeNull() + await fireEvent.load(backdrop as HTMLImageElement) + await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(2)) + return container.querySelectorAll('img')[1] +} + +function getDialogCall(index = 0) { + const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [ + unknown, + Record, + Record void>, + Record, + ] + return { events, options, props } +} + +describe('SubscribeShareCard', () => { + beforeEach(() => { + observeElementsImmediately() + mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() }) + }) + + it('renders sharing metadata and reveals the cached poster after the backdrop loads', async () => { + const { container, media } = await renderCard({}, true) + + expect(screen.getByText(media.share_title!)).toBeInTheDocument() + expect(screen.getByText(media.share_comment!)).toBeInTheDocument() + expect(screen.getByText(media.share_user!)).toBeInTheDocument() + expect(screen.getByText(media.count!.toLocaleString())).toBeInTheDocument() + expect(screen.getByText(formatDateDifference(media.date!))).toBeInTheDocument() + + const backdrop = container.querySelector('img') + expect(backdrop).not.toBeNull() + expect((backdrop as HTMLImageElement).src).toContain('system/cache/image?url=') + expect((backdrop as HTMLImageElement).src).toContain(encodeURIComponent(media.backdrop!)) + + const poster = await loadPoster(container) + expect(poster.src).toContain('system/cache/image?url=') + expect(poster.src).toContain(encodeURIComponent(media.poster!)) + + const dateMetadata = screen.getByText(formatDateDifference(media.date!)).closest('.v-card-text') + expect(dateMetadata?.querySelector('[data-icon="mdi-calendar"], [data-icon="mdi:calendar"]')).not.toBeNull() + }) + + it('falls back to the poster when the backdrop is missing and hides a zero reuse count', async () => { + const { container, media } = await renderCard({ backdrop: undefined, count: 0 }) + + const backdrop = container.querySelector('img') + expect(backdrop).not.toBeNull() + expect((backdrop as HTMLImageElement).src).toContain(media.poster!) + expect(screen.queryByText('0')).not.toBeInTheDocument() + }) + + it.each([ + ['TMDB before Douban', { doubanid: '2202', tmdbid: 1101 }, 'tmdb:1101'], + ['Douban without TMDB', { doubanid: '2202', tmdbid: undefined }, 'douban:2202'], + ['Bangumi without TMDB or Douban', { bangumiid: 3303, doubanid: undefined, tmdbid: undefined }, 'bangumi:3303'], + ] as const)('routes media details with %s while keeping the fork dialog closed', async (_case, ids, mediaid) => { + const { container, media } = await renderCard(ids) + const poster = await loadPoster(container) + + await fireEvent.click(poster) + + expect(mocks.routerPush).toHaveBeenCalledWith({ + path: '/media', + query: { + mediaid, + title: media.name, + type: media.type, + year: media.year, + }, + }) + expect(mocks.openSharedDialog).not.toHaveBeenCalled() + }) + + it('opens the fork dialog with the exact media and replaces it with editing after fork success', async () => { + const { container, media } = await renderCard() + const card = container.querySelector('.v-card') + expect(card).not.toBeNull() + + await fireEvent.click(card as HTMLElement) + + expect(mocks.openSharedDialog).toHaveBeenCalledOnce() + const forkDialog = getDialogCall() + expect(forkDialog.props).toEqual({ media }) + expect(forkDialog.options).toEqual({ closeOn: ['close', 'fork', 'delete'] }) + + forkDialog.events.fork(4701) + + expect(mocks.openSharedDialog).toHaveBeenCalledTimes(2) + const editDialog = getDialogCall(1) + expect(editDialog.props).toEqual({ subid: 4701 }) + expect(editDialog.events).toEqual({}) + expect(editDialog.options).toEqual({ closeOn: ['close', 'save', 'remove'] }) + }) + + it('forwards deletion from the fork dialog without depending on its response payload', async () => { + const { container, emitted } = await renderCard() + const card = container.querySelector('.v-card') + expect(card).not.toBeNull() + await fireEvent.click(card as HTMLElement) + + getDialogCall().events.delete({ id: 9999 }) + + expect(emitted('delete')).toHaveLength(1) + expect(emitted('delete')?.[0]).toEqual([]) + }) +}) diff --git a/src/components/dialog/ForkSubscribeDialog.vue b/src/components/dialog/ForkSubscribeDialog.vue index fda5d47a..cf475f1c 100644 --- a/src/components/dialog/ForkSubscribeDialog.vue +++ b/src/components/dialog/ForkSubscribeDialog.vue @@ -54,7 +54,8 @@ async function queryFollowUsers() { const result: { [key: string]: any } = await api.get('system/setting/public/FollowSubscribers') followUsers.value = result.data?.value ?? [] } catch (error) { - console.log(error) + console.error(error) + $toast.error(t('subscribe.requestFailed')) } } @@ -66,7 +67,8 @@ async function followUser() { queryFollowUsers() } } catch (error) { - console.log(error) + console.error(error) + $toast.error(t('subscribe.requestFailed')) } } @@ -82,7 +84,8 @@ async function unfollowUser() { queryFollowUsers() } } catch (error) { - console.log(error) + console.error(error) + $toast.error(t('subscribe.requestFailed')) } } @@ -96,6 +99,7 @@ const posterUrl = computed(() => { function getMediaId() { if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}` else if (props.media?.doubanid) return `douban:${props.media?.doubanid}` + else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}` } // 查看媒体详情 @@ -129,6 +133,12 @@ async function doFork() { } } catch (error) { console.error(error) + $toast.error( + t('subscribe.addFailed', { + name: props.media?.share_title, + message: t('subscribe.requestFailed'), + }), + ) } finally { processing.value = false doneNProgress() @@ -151,12 +161,13 @@ async function doDelete() { if (result.success) { $toast.success(t('subscribe.cancelSuccess')) // 完成 - emit('delete', result.data.id) + emit('delete') } else { $toast.error(t('subscribe.cancelFailed', { message: result.message })) } } catch (error) { console.error(error) + $toast.error(t('subscribe.cancelFailed', { message: t('subscribe.requestFailed') })) } finally { deleting.value = false doneNProgress() diff --git a/src/components/dialog/SubscribeShareDialog.vue b/src/components/dialog/SubscribeShareDialog.vue index a3136c8b..82a81b92 100644 --- a/src/components/dialog/SubscribeShareDialog.vue +++ b/src/components/dialog/SubscribeShareDialog.vue @@ -27,7 +27,9 @@ const shareDoing = ref(false) // 订阅编辑表单 const shareForm = ref({ subscribe_id: props.sub?.id ?? 0, - share_title: `${props.sub?.name} ${formatSeason(props.sub?.season ? props.sub?.season.toString() : '')}`, + share_title: `${props.sub?.name} ${formatSeason( + props.sub?.season === null || props.sub?.season === undefined ? '' : props.sub.season.toString(), + )}`, }) // 分享订阅 @@ -36,7 +38,6 @@ async function doShare() { try { shareDoing.value = true const result: { [key: string]: any } = await api.post('subscribe/share', shareForm.value) - shareDoing.value = false // 提示 if (result.success) { $toast.success(t('dialog.subscribeShare.shareSuccess', { name: props.sub?.name })) @@ -46,7 +47,15 @@ async function doShare() { $toast.error(t('dialog.subscribeShare.shareFailed', { name: props.sub?.name, message: result.message })) } } catch (e) { - console.log(e) + console.error(e) + $toast.error( + t('dialog.subscribeShare.shareFailed', { + name: props.sub?.name, + message: t('subscribe.requestFailed'), + }), + ) + } finally { + shareDoing.value = false } } @@ -64,7 +73,11 @@ const $toast = useToast() {{ t('dialog.subscribeShare.shareSubscription') }} {{ props.sub?.name }} - {{ props.sub?.season ? t('dialog.subscribeShare.season', { number: props.sub?.season }) : '' }} + {{ + props.sub?.season === null || props.sub?.season === undefined + ? '' + : t('dialog.subscribeShare.season', { number: props.sub.season }) + }} diff --git a/src/components/dialog/SubscribeShareStatisticsDialog.vue b/src/components/dialog/SubscribeShareStatisticsDialog.vue index 328910b3..e70af2ac 100644 --- a/src/components/dialog/SubscribeShareStatisticsDialog.vue +++ b/src/components/dialog/SubscribeShareStatisticsDialog.vue @@ -23,14 +23,19 @@ const statistics = ref([]) // 是否加载中 const loading = ref(false) +// 本地统计接口是否加载失败;合法空数组仍使用空数据状态。 +const loadError = ref(false) + // 获取统计数据 async function fetchStatistics() { try { loading.value = true + loadError.value = false const data: SubscribeShareStatistics[] = await api.get('subscribe/share/statistics') statistics.value = data } catch (error) { console.error('获取分享统计数据失败:', error) + loadError.value = true } finally { loading.value = false } @@ -130,6 +135,13 @@ onMounted(() => { +
+ +
{{ t('subscribe.requestFailed') }}
+ + {{ t('common.retry') }} + +
{{ t('subscribe.noStatisticsData') }}
diff --git a/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts b/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts new file mode 100644 index 00000000..d853c072 --- /dev/null +++ b/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts @@ -0,0 +1,395 @@ +import type { SubscribeShare } from '@/api/types' +import ForkSubscribeDialog from '@/components/dialog/ForkSubscribeDialog.vue' +import { screen, waitFor } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { createSubscribeShare } from '@tests/support/factories/subscribe' +import { + deleteSubscribeShareHandler, + followSubscriberHandler, + followSubscribersSettingHandler, + forkSubscribeHandler, + unfollowSubscriberHandler, +} from '@tests/support/msw/handlers/subscribe' +import { server } from '@tests/support/msw/server' +import { renderWithProviders } from '@tests/support/render' +import { flushPromises } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + routerPush: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock('@/router', () => ({ + default: { + push: (...args: unknown[]) => mocks.routerPush(...args), + }, +})) + +vi.mock('@/api/nprogress', () => ({ + doneNProgress: vi.fn(), + startNProgress: vi.fn(), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), +})) + +const DialogCloseButtonStub = defineComponent({ + name: 'VDialogCloseBtn', + emits: ['click'], + setup(_props, { emit }) { + return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭') + }, +}) + +const PosterStub = defineComponent({ + name: 'VImg', + emits: ['click'], + setup(_props, { emit }) { + return () => + h( + 'button', + { + 'aria-label': '查看媒体详情', + type: 'button', + onClick: () => emit('click'), + }, + '查看媒体详情', + ) + }, +}) + +interface MediaIdentifiers { + bangumiid?: number + doubanid?: string + tmdbid?: number +} + +const mediaDetailCases: Array<[string, MediaIdentifiers, string]> = [ + ['TMDB', { tmdbid: 6301 }, 'tmdb:6301'], + ['Douban', { doubanid: 'db-6302', tmdbid: undefined }, 'douban:db-6302'], + ['Bangumi', { bangumiid: 6303, doubanid: undefined, tmdbid: undefined }, 'bangumi:6303'], +] + +function createDeferred() { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} + +async function renderDialog( + media: SubscribeShare = createSubscribeShare(), + settings: Record = {}, +) { + const events = { + close: vi.fn(), + delete: vi.fn(), + fork: vi.fn(), + } + const result = await renderWithProviders(ForkSubscribeDialog, { + global: { + components: { + VDialogCloseBtn: DialogCloseButtonStub, + }, + stubs: { + VImg: PosterStub, + }, + }, + initialState: { + globalSettings: { + data: { + GLOBAL_IMAGE_CACHE: false, + SUBSCRIBE_SHARE_MANAGE: false, + USER_UNIQUE_ID: 'current-user', + ...settings, + }, + }, + }, + props: { + media, + modelValue: true, + onClose: events.close, + onDelete: events.delete, + onFork: events.fork, + }, + }) + await flushPromises() + await flushPromises() + + return { ...result, events, media } +} + +describe('ForkSubscribeDialog follow behavior', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('loads the followed users and shows the current action', async () => { + const media = createSubscribeShare({ share_uid: 'followed-user' }) + const requested = vi.fn() + server.use(followSubscribersSettingHandler(['followed-user'], 200, requested)) + + await renderDialog(media) + + expect(await screen.findByRole('button', { name: '取消关注' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '关注' })).not.toBeInTheDocument() + expect(requested).toHaveBeenCalledOnce() + }) + + it('follows a share user and refreshes the action from the server setting', async () => { + const media = createSubscribeShare({ share_uid: 'new-follow-user' }) + const users: string[] = [] + const writeRequest = vi.fn((url: URL) => { + users.push(url.searchParams.get('share_uid') || '') + }) + server.use( + followSubscribersSettingHandler(users), + followSubscriberHandler({ success: true }, 200, writeRequest), + ) + const user = userEvent.setup() + await renderDialog(media) + + await user.click(await screen.findByRole('button', { name: '关注' })) + + expect(await screen.findByRole('button', { name: '取消关注' })).toBeInTheDocument() + expect(writeRequest).toHaveBeenCalledOnce() + expect(writeRequest.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/follow') + expect(writeRequest.mock.calls[0][0].searchParams.get('share_uid')).toBe('new-follow-user') + }) + + it('unfollows a share user and refreshes the action from the server setting', async () => { + const media = createSubscribeShare({ share_uid: 'old-follow-user' }) + const users = ['old-follow-user'] + const writeRequest = vi.fn((url: URL) => { + users.splice(users.indexOf(url.searchParams.get('share_uid') || ''), 1) + }) + server.use( + followSubscribersSettingHandler(users), + unfollowSubscriberHandler({ success: true }, 200, writeRequest), + ) + const user = userEvent.setup() + await renderDialog(media) + + await user.click(await screen.findByRole('button', { name: '取消关注' })) + + expect(await screen.findByRole('button', { name: '关注' })).toBeInTheDocument() + expect(writeRequest).toHaveBeenCalledOnce() + expect(writeRequest.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/follow') + expect(writeRequest.mock.calls[0][0].searchParams.get('share_uid')).toBe('old-follow-user') + }) + + it('does not show follow actions when the share has no UID', async () => { + const media = createSubscribeShare({ share_uid: undefined }) + server.use(followSubscribersSettingHandler([])) + + await renderDialog(media) + await waitFor(() => expect(screen.getByRole('button', { name: '订阅' })).toBeInTheDocument()) + + expect(screen.queryByRole('button', { name: '关注' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '取消关注' })).not.toBeInTheDocument() + }) + + it('shows visible feedback when the followed-user list request fails', async () => { + server.use(followSubscribersSettingHandler([], 500)) + + await renderDialog(createSubscribeShare()) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试')) + }) + + it('keeps the follow action and shows feedback when the follow request fails', async () => { + const media = createSubscribeShare({ share_uid: 'failed-follow-user' }) + server.use(followSubscribersSettingHandler([]), followSubscriberHandler({ success: true }, 500)) + const user = userEvent.setup() + await renderDialog(media) + + await user.click(await screen.findByRole('button', { name: '关注' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试')) + expect(screen.getByRole('button', { name: '关注' })).toBeInTheDocument() + }) + + it('keeps the unfollow action and shows feedback when the unfollow request fails', async () => { + const media = createSubscribeShare({ share_uid: 'failed-unfollow-user' }) + server.use( + followSubscribersSettingHandler(['failed-unfollow-user']), + unfollowSubscriberHandler({ success: true }, 500), + ) + const user = userEvent.setup() + await renderDialog(media) + + await user.click(await screen.findByRole('button', { name: '取消关注' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试')) + expect(screen.getByRole('button', { name: '取消关注' })).toBeInTheDocument() + }) +}) + +describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('keeps the fork button pending and emits the created subscription ID on success', async () => { + const media = createSubscribeShare({ id: 6101, share_title: '待复用分享' }) + const deferred = createDeferred() + const forkPayload = vi.fn(() => deferred.promise) + server.use( + followSubscribersSettingHandler([]), + forkSubscribeHandler({ data: { id: 7101 }, success: true }, 200, forkPayload), + ) + const user = userEvent.setup() + const { events } = await renderDialog(media) + const forkButton = screen.getByRole('button', { name: '订阅' }) + + await user.click(forkButton) + await waitFor(() => expect(forkPayload).toHaveBeenCalledOnce()) + expect(forkButton).toBeDisabled() + expect(events.fork).not.toHaveBeenCalled() + + deferred.resolve() + await waitFor(() => expect(events.fork).toHaveBeenCalledWith(7101)) + expect(forkPayload).toHaveBeenCalledWith(media) + expect(mocks.toastSuccess).toHaveBeenCalledWith('添加待复用分享成功!') + }) + + it('reports a fork business failure and does not emit', async () => { + server.use( + followSubscribersSettingHandler([]), + forkSubscribeHandler({ message: '订阅已存在', success: false }), + ) + const user = userEvent.setup() + const { events } = await renderDialog(createSubscribeShare({ share_title: '冲突分享' })) + + await user.click(screen.getByRole('button', { name: '订阅' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('添加冲突分享失败:订阅已存在!')) + expect(events.fork).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: '订阅' })).not.toBeDisabled() + }) + + it('reports an HTTP fork failure, restores the action, and does not emit', async () => { + server.use(followSubscribersSettingHandler([]), forkSubscribeHandler({ success: true }, 500)) + const user = userEvent.setup() + const { events } = await renderDialog(createSubscribeShare()) + + await user.click(screen.getByRole('button', { name: '订阅' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('请求失败'))) + expect(events.fork).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: '订阅' })).not.toBeDisabled() + }) + + it.each([ + ['the owner', 'owned-share', false, true], + ['a share manager', 'other-user', true, true], + ['another ordinary user', 'other-user', false, false], + ])('shows delete permission for %s', async (_case, shareUid, canManage, visible) => { + server.use(followSubscribersSettingHandler([])) + + await renderDialog(createSubscribeShare({ share_uid: shareUid }), { + SUBSCRIBE_SHARE_MANAGE: canManage, + USER_UNIQUE_ID: 'owned-share', + }) + await waitFor(() => expect(screen.getByRole('button', { name: '订阅' })).toBeInTheDocument()) + + if (visible) expect(screen.getByRole('button', { name: '取消分享' })).toBeInTheDocument() + else expect(screen.queryByRole('button', { name: '取消分享' })).not.toBeInTheDocument() + }) + + it('deletes the exact share and emits success without relying on response data.id', async () => { + const media = createSubscribeShare({ id: 6201, share_uid: 'owned-share' }) + const deferred = createDeferred() + const deleteRequest = vi.fn((_url: URL) => deferred.promise) + server.use( + followSubscribersSettingHandler([]), + deleteSubscribeShareHandler(6201, { success: true }, 200, deleteRequest), + ) + const user = userEvent.setup() + const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' }) + const deleteButton = screen.getByRole('button', { name: '取消分享' }) + + await user.click(deleteButton) + await waitFor(() => expect(deleteRequest).toHaveBeenCalledOnce()) + expect(deleteButton).toBeDisabled() + expect(deleteRequest.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/share/6201') + + deferred.resolve() + await waitFor(() => expect(events.delete).toHaveBeenCalledOnce()) + expect(events.delete).toHaveBeenCalledWith() + expect(mocks.toastSuccess).toHaveBeenCalledWith('已取消订阅!') + }) + + it('reports a delete business failure and does not emit', async () => { + const media = createSubscribeShare({ id: 6202, share_uid: 'owned-share' }) + server.use( + followSubscribersSettingHandler([]), + deleteSubscribeShareHandler(6202, { message: '没有删除权限', success: false }), + ) + const user = userEvent.setup() + const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' }) + + await user.click(screen.getByRole('button', { name: '取消分享' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('取消订阅失败:没有删除权限!')) + expect(events.delete).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: '取消分享' })).not.toBeDisabled() + }) + + it('reports an HTTP delete failure, restores the action, and does not emit', async () => { + const media = createSubscribeShare({ id: 6203, share_uid: 'owned-share' }) + server.use(followSubscribersSettingHandler([]), deleteSubscribeShareHandler(6203, { success: true }, 500)) + const user = userEvent.setup() + const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' }) + + await user.click(screen.getByRole('button', { name: '取消分享' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('请求失败'))) + expect(events.delete).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: '取消分享' })).not.toBeDisabled() + }) + + it('emits close from the dialog close control', async () => { + server.use(followSubscribersSettingHandler([])) + const user = userEvent.setup() + const { events } = await renderDialog(createSubscribeShare()) + + await user.click(screen.getByRole('button', { name: '关闭' })) + + expect(events.close).toHaveBeenCalledOnce() + }) + + it.each(mediaDetailCases)('routes %s shares to their media details', async (_source, identifiers, expectedMediaId) => { + const media: SubscribeShare = { + ...createSubscribeShare({ + doubanid: identifiers.doubanid, + tmdbid: identifiers.tmdbid, + }), + bangumiid: identifiers.bangumiid, + } + server.use(followSubscribersSettingHandler([])) + const user = userEvent.setup() + await renderDialog(media) + + await user.click(screen.getByRole('button', { name: '查看媒体详情' })) + + expect(mocks.routerPush).toHaveBeenCalledWith({ + path: '/media', + query: { + mediaid: expectedMediaId, + title: media.name, + type: media.type, + year: media.year, + }, + }) + }) +}) diff --git a/src/components/dialog/__tests__/SubscribeShareDialog.spec.ts b/src/components/dialog/__tests__/SubscribeShareDialog.spec.ts new file mode 100644 index 00000000..3815214b --- /dev/null +++ b/src/components/dialog/__tests__/SubscribeShareDialog.spec.ts @@ -0,0 +1,170 @@ +import SubscribeShareDialog from '@/components/dialog/SubscribeShareDialog.vue' +import { screen, waitFor } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { createSubscribe } from '@tests/support/factories/subscribe' +import { shareSubscribeHandler } from '@tests/support/msw/handlers/subscribe' +import { server } from '@tests/support/msw/server' +import { renderWithProviders } from '@tests/support/render' +import { flushPromises } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), +})) + +const DialogCloseButtonStub = defineComponent({ + name: 'VDialogCloseBtn', + emits: ['click'], + setup(_props, { emit }) { + return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭') + }, +}) + +function createDeferred() { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} + +async function renderDialog(season = 2, name = '分享创建测试剧') { + const close = vi.fn() + const sub = createSubscribe({ + id: 5201, + name, + season, + tmdbid: 52010, + type: '电视剧', + }) + const result = await renderWithProviders(SubscribeShareDialog, { + props: { + modelValue: true, + onClose: close, + sub, + }, + global: { + components: { + VDialogCloseBtn: DialogCloseButtonStub, + }, + }, + }) + + return { ...result, close, sub } +} + +async function fillRequiredFields(comment = '覆盖精确订阅规则', shareUser = '测试分享人') { + const user = userEvent.setup() + await user.type(screen.getByLabelText('说明'), comment) + await user.type(screen.getByLabelText('分享用户'), shareUser) + return user +} + +describe('SubscribeShareDialog', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it.each([ + ['normal season', 2, 'S02', '第 2 季'], + ['special season zero', 0, 'S00', '第 0 季'], + ])('renders the readonly default title and subtitle for %s', async (_case, season, seasonLabel, subtitle) => { + const { sub } = await renderDialog(season) + + expect(screen.getByLabelText('标题')).toHaveValue(`${sub.name} ${seasonLabel}`) + expect(document.querySelector('.v-card-subtitle')).toHaveTextContent(`${sub.name} ${subtitle}`) + }) + + it.each([ + ['description', '', '测试分享人'], + ['sharing user', '覆盖精确订阅规则', ''], + ])('blocks submission when the %s is missing', async (_case, comment, shareUser) => { + const requested = vi.fn() + server.use(shareSubscribeHandler({ success: true }, 200, requested)) + await renderDialog() + const user = userEvent.setup() + + if (comment) await user.type(screen.getByLabelText('说明'), comment) + if (shareUser) await user.type(screen.getByLabelText('分享用户'), shareUser) + + await user.click(screen.getByRole('button', { name: '确认分享' })) + await flushPromises() + + expect(requested).not.toHaveBeenCalled() + expect(mocks.toastSuccess).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('submits the exact payload once, stays pending, then closes after success', async () => { + const deferred = createDeferred() + const payloads: unknown[] = [] + server.use( + shareSubscribeHandler({ success: true }, 200, async payload => { + payloads.push(payload) + await deferred.promise + }), + ) + const { close, sub } = await renderDialog() + const user = await fillRequiredFields('只保留精确字段', '分享者甲') + const submit = screen.getByRole('button', { name: '确认分享' }) + + await user.click(submit) + + await waitFor(() => expect(payloads).toHaveLength(1)) + expect(payloads[0]).toEqual({ + share_comment: '只保留精确字段', + share_title: `${sub.name} S02`, + share_user: '分享者甲', + subscribe_id: sub.id, + }) + expect(submit).toBeDisabled() + expect(close).not.toHaveBeenCalled() + + deferred.resolve() + + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith(`${sub.name} 分享成功!`)) + expect(close).toHaveBeenCalledOnce() + expect(submit).not.toBeDisabled() + }) + + it('keeps the dialog open and reports a business failure', async () => { + server.use(shareSubscribeHandler({ message: '远端拒绝', success: false })) + const { close, sub } = await renderDialog() + const user = await fillRequiredFields() + const submit = screen.getByRole('button', { name: '确认分享' }) + + await user.click(submit) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(`${sub.name} 分享失败:远端拒绝!`)) + expect(close).not.toHaveBeenCalled() + expect(submit).not.toBeDisabled() + }) + + it('recovers from an HTTP failure, keeps the dialog open, and reports the failure', async () => { + server.use(shareSubscribeHandler({ message: '服务异常', success: false }, 500)) + const { close, sub } = await renderDialog(2, 'HTTP失败剧') + const user = await fillRequiredFields() + const submit = screen.getByRole('button', { name: '确认分享' }) + + await user.click(submit) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining(`${sub.name} 分享失败`))) + expect(close).not.toHaveBeenCalled() + expect(submit).not.toBeDisabled() + }) + + it('emits close from the dialog close control', async () => { + const user = userEvent.setup() + const { close } = await renderDialog() + + await user.click(screen.getByRole('button', { name: '关闭' })) + + expect(close).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/dialog/__tests__/SubscribeShareStatisticsDialog.spec.ts b/src/components/dialog/__tests__/SubscribeShareStatisticsDialog.spec.ts new file mode 100644 index 00000000..0286a6eb --- /dev/null +++ b/src/components/dialog/__tests__/SubscribeShareStatisticsDialog.spec.ts @@ -0,0 +1,174 @@ +import SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue' +import { screen, waitFor } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { createSubscribeShareStatistics } from '@tests/support/factories/subscribe' +import { subscribeShareStatisticsHandler } from '@tests/support/msw/handlers/subscribe' +import { server } from '@tests/support/msw/server' +import { renderWithProviders } from '@tests/support/render' +import { defineComponent, h } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const DialogCloseButtonStub = defineComponent({ + name: 'VDialogCloseBtn', + emits: ['click'], + setup(_props, { emit }) { + return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭') + }, +}) + +const LoadingBannerStub = defineComponent({ + name: 'LoadingBanner', + setup() { + return () => h('div', { role: 'status' }, '加载中') + }, +}) + +function createDeferred() { + let resolve!: () => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} + +async function renderDialog() { + const close = vi.fn() + const result = await renderWithProviders(SubscribeShareStatisticsDialog, { + global: { + components: { + VDialogCloseBtn: DialogCloseButtonStub, + }, + stubs: { + LoadingBanner: LoadingBannerStub, + }, + }, + props: { + modelValue: true, + onClose: close, + }, + }) + return { ...result, close } +} + +describe('SubscribeShareStatisticsDialog', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('shows loading until statistics arrive', async () => { + const deferred = createDeferred() + const requested = vi.fn(() => deferred.promise) + server.use( + subscribeShareStatisticsHandler( + [createSubscribeShareStatistics({ share_user: '延迟统计用户' })], + 200, + requested, + ), + ) + + renderDialog() + + expect(await screen.findByRole('status')).toHaveTextContent('加载中') + await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + + deferred.resolve() + expect((await screen.findAllByText('延迟统计用户')).length).toBeGreaterThan(0) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('sorts by reuse count and preserves source order for ties', async () => { + server.use( + subscribeShareStatisticsHandler([ + createSubscribeShareStatistics({ share_user: '并列第一甲', total_reuse_count: 50 }), + createSubscribeShareStatistics({ share_user: '并列第一乙', total_reuse_count: 50 }), + createSubscribeShareStatistics({ share_user: '第三名', total_reuse_count: 30 }), + createSubscribeShareStatistics({ share_user: '并列末位甲', total_reuse_count: 10 }), + createSubscribeShareStatistics({ share_user: '并列末位乙', total_reuse_count: 10 }), + ]), + ) + + await renderDialog() + + expect(await screen.findByText('#4')).toBeInTheDocument() + const firstPlace = document.body.querySelector('.first-place')?.parentElement + expect(firstPlace).toHaveTextContent('并列第一甲') + expect(firstPlace).not.toHaveTextContent('并列第一乙') + const renderedText = document.body.textContent || '' + expect(renderedText.indexOf('并列末位甲')).toBeLessThan(renderedText.indexOf('并列末位乙')) + expect(screen.getByText('#5')).toBeInTheDocument() + }) + + it.each([1, 2, 3, 4])('renders a complete ranking with %i participant(s)', async count => { + const statistics = Array.from({ length: count }, (_, index) => + createSubscribeShareStatistics({ + share_user: `人数场景用户 ${count}-${index + 1}`, + total_reuse_count: count - index, + }), + ) + server.use(subscribeShareStatisticsHandler(statistics)) + + await renderDialog() + + for (const item of statistics) { + expect((await screen.findAllByText(item.share_user!)).length).toBeGreaterThan(0) + } + expect(screen.queryByText('暂无分享统计数据')).not.toBeInTheDocument() + if (count === 4) expect(screen.getByText('#4')).toBeInTheDocument() + }) + + it('renders missing aggregate fields across the podium and remaining rankings', async () => { + const statistics = Array.from({ length: 4 }, () => + createSubscribeShareStatistics({ + share_count: undefined, + share_user: undefined, + total_reuse_count: undefined, + }), + ) + server.use(subscribeShareStatisticsHandler(statistics)) + + await renderDialog() + + expect((await screen.findAllByText('未知')).length).toBeGreaterThan(0) + expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(2) + expect(screen.getByText('#4')).toBeInTheDocument() + }) + + it('treats an empty statistics list as valid empty data', async () => { + server.use(subscribeShareStatisticsHandler([])) + + await renderDialog() + + expect(await screen.findByText('暂无分享统计数据')).toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('shows an HTTP error separately and retries the same request on demand', async () => { + const failedRequest = vi.fn() + server.use(subscribeShareStatisticsHandler([], 500, failedRequest)) + const user = userEvent.setup() + await renderDialog() + + expect(await screen.findByText('请求失败,请稍后重试')).toBeInTheDocument() + expect(failedRequest).toHaveBeenCalledOnce() + + server.use( + subscribeShareStatisticsHandler([ + createSubscribeShareStatistics({ share_user: '重试恢复用户', total_reuse_count: 20 }), + ]), + ) + await user.click(screen.getByRole('button', { name: '重试' })) + + expect((await screen.findAllByText('重试恢复用户')).length).toBeGreaterThan(0) + expect(screen.queryByText('请求失败,请稍后重试')).not.toBeInTheDocument() + }) + + it('emits close from the dialog close control', async () => { + server.use(subscribeShareStatisticsHandler([])) + const user = userEvent.setup() + const { close } = await renderDialog() + + await user.click(screen.getByRole('button', { name: '关闭' })) + + expect(close).toHaveBeenCalledOnce() + }) +}) diff --git a/src/views/subscribe/SubscribePopularView.vue b/src/views/subscribe/SubscribePopularView.vue index 8085fa23..d8c343c0 100644 --- a/src/views/subscribe/SubscribePopularView.vue +++ b/src/views/subscribe/SubscribePopularView.vue @@ -25,15 +25,18 @@ const apipath = 'subscribe/popular' // 当前页码 const page = ref(1) -// 是否加载中 -const loading = ref(false) - // 是否加载完成 const isRefreshed = ref(false) +// 当前列表请求是否失败;合法空数组仍使用空数据状态。 +const loadError = ref(false) + // 数据列表 const dataList = ref([]) -const currData = ref([]) + +// 筛选重置允许新旧请求短暂并行,只接纳当前代次的响应。 +let requestGeneration = 0 +const loadingGenerations = new Set() // 筛选参数 const filterParams = reactive({ @@ -48,9 +51,11 @@ const filterParams = reactive({ const currentKey = ref(0) function resetData() { + requestGeneration++ dataList.value = [] page.value = 1 isRefreshed.value = false + loadError.value = false currentKey.value++ } @@ -140,69 +145,62 @@ function getParams() { } // 获取列表数据 -async function fetchData({ done }: { done: any }) { - try { - // 如果正在加载中,直接返回 - if (loading.value) { - done('ok') - return - } +async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'ok') => void }) { + const generation = requestGeneration - // 加载到满屏或者加载出错 - if (!hasScroll()) { - // 加载多次 - while (!hasScroll()) { - // 设置加载中 - loading.value = true - // 请求API - currData.value = await api.get(apipath, { - params: getParams(), - }) - // 取消加载中 - loading.value = false - // 标计为已请求完成 - isRefreshed.value = true - if (currData.value.length === 0) { - // 如果没有数据,跳出 - done('empty') - return - } - // 合并数据 - dataList.value = [...dataList.value, ...currData.value] - // 页码+1 - page.value++ - // 返回加载成功 - done('ok') - await nextTick() - } - } else { - // 设置加载中 - loading.value = true - // 请求API - currData.value = await api.get(apipath, { + // 同一筛选条件只允许一个分页请求在途。 + if (loadingGenerations.has(generation)) { + return + } + + loadingGenerations.add(generation) + loadError.value = false + + try { + while (generation === requestGeneration) { + const currentData: MediaInfo[] = await api.get(apipath, { params: getParams(), }) - loading.value = false - // 标计为已请求完成 + + if (generation !== requestGeneration) return + isRefreshed.value = true - if (currData.value.length === 0) { - // 如果没有数据,跳出 + if (currentData.length === 0) { done('empty') - } else { - // 合并数据 - dataList.value = [...dataList.value, ...currData.value] - // 页码+1 - page.value++ - // 返回加载成功 - done('ok') + return } + + dataList.value = [...dataList.value, ...currentData] + page.value++ + done('ok') + await nextTick() + + if (hasScroll()) return } } catch (error) { + if (generation !== requestGeneration) return + console.error(error) - // 返回加载失败 + isRefreshed.value = true + loadError.value = true done('error') + } finally { + loadingGenerations.delete(generation) } } + +/** 使用媒体来源、稳定 ID 与季号区分热门条目。 */ +function getMediaItemKey(item: MediaInfo) { + const mediaId = item.tmdb_id + ? `tmdb:${item.tmdb_id}` + : item.douban_id + ? `douban:${item.douban_id}` + : item.bangumi_id + ? `bangumi:${item.bangumi_id}` + : `${item.mediaid_prefix ?? 'media'}:${item.media_id ?? item.title ?? ''}` + + return `${item.source ?? 'unknown'}:${mediaId}:season:${item.season ?? 'all'}` +}